Sequelize 是 Node.js 生态里最常用的 ORM 之一,而 TypeScript 的引入让模型定义和关联配置多了一层类型约束。很多人在 JavaScript 里用得顺手的 hasMany、belongsToMany,搬到 TypeScript 项目后就开始出问题:要么类型推断变成 any,要么外键名对不上导致查询结果为空。这篇文章把 Sequelize 的三种关联关系的配置方式彻底讲清楚,重点放在多对多关联的 through 表上,并给出可以直接运行的 TypeScript 示例。

Sequelize 关联的本质:外键挂在哪里
配置关联之前,先理解 Sequelize 的设计思路。所谓关联,本质上就是在数据库表之间建立外键引用,Sequelize 提供的四类方法只是决定外键放在哪张表上:hasOne 表示目标表持有外键;hasMany 同样是目标表持有外键,但关系是一对多;belongsTo 表示源表自己持有外键;belongsToMany 则通过一张中间表来承载两个外键。
举个例子,用户和邮箱是一对一关系。如果写 User.hasOne(Email),外键 userId 会出现在 emails 表上;如果写 Email.belongsTo(User),外键同样是 userId 在 emails 表上。这两个定义描述的是同一件事,通常建议成对声明,这样模型才支持双向的预加载查询。只声明一边,从另一边查关联就会报错。
还有一点容易被忽略:外键的默认命名规则。Sequelize 默认会用源模型名称的 camelCase 形式加 Id 后缀,也就是 userId。如果你的数据库表已经存在,且外键名是自定义的(比如 uid),就必须显式传入 foreignKey 参数,否则 Sequelize 会按默认名去找列,查询结果是空的但不报错,这类问题排查起来非常折磨人。
一对多与一对一:belongsTo 和 hasMany 的组合
一对多是业务中最常见的关系,比如一个分类下有多篇文章。TypeScript 下的标准写法是两边都声明:分类侧用 hasMany,文章侧用 belongsTo,并且保证两边的 foreignKey 指向同一个列名。
import { DataTypes, Model, InferAttributes, InferCreationAttributes, CreationOptional } from 'sequelize';
import { sequelize } from './db';
// 文章模型,持有 categoryId 外键
class Post extends Model<InferAttributes<Post>, InferCreationAttributes<Post>> {
declare id: CreationOptional<number>;
declare title: string;
declare categoryId: number; // 外键列要在模型上声明
}
Post.init({
id: { type: DataTypes.INTEGER.UNSIGNED, autoIncrement: true, primaryKey: true },
title: { type: DataTypes.STRING, allowNull: false },
categoryId: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false }
}, { sequelize, tableName: 'posts', underscored: true });
// 分类模型
class Category extends Model<InferAttributes<Category>, InferCreationAttributes<Category>> {
declare id: CreationOptional<number>;
declare name: string;
}
Category.init({
id: { type: DataTypes.INTEGER.UNSIGNED, autoIncrement: true, primaryKey: true },
name: { type: DataTypes.STRING, allowNull: false }
}, { sequelize, tableName: 'categories', underscored: true });
// 成对声明,foreignKey 必须一致
Category.hasMany(Post, { foreignKey: 'categoryId', as: 'posts' });
Post.belongsTo(Category, { foreignKey: 'categoryId', as: 'category' }');注意几个细节。第一,用了 underscored: true 后,模型属性 categoryId 会映射到数据库的 category_id 列,Sequelize 会自动做驼峰与下划线的转换,不需要在 foreignKey 里写 category_id。第二,as 别名决定了预加载和结果里的属性名,比如 include: ['posts'] 对应上面定义的别名。第三,TypeScript 下外键列必须用 declare 显式声明在模型类上,否则 post.get() 拿到的类型里没有这个字段。
一对一关系把 hasMany 换成 hasOne 即可,其余规则完全一致。查询时配合 include 使用:
const posts = await Post.findAll({
include: [{ model: Category, as: 'category' }]
});
// 结果中每条 post 对象上会挂 category 属性多对多:belongsToMany 与 through 中间表
多对多关系,比如学生和课程,需要一个中间表来存两边的关联。Sequelize 的 belongsToMany 通过 through 参数指定中间表。这里是最容易踩坑的地方:两边声明必须共用同一个 through 模型,且外键要分别指定清楚。
import { Model, InferAttributes, InferCreationAttributes, CreationOptional, DataTypes } from 'sequelize';
class Student extends Model<InferAttributes<Student>, InferCreationAttributes<Student>> {
declare id: CreationOptional<number>;
declare name: string;
}
class Course extends Model<InferAttributes<Course>, InferCreationAttributes<Course>> {
declare id: CreationOptional<number>;
declare title: string;
}
// 中间表模型,必须显式定义,不要让 Sequelize 隐式创建
class Enrollment extends Model<InferAttributes<Enrollment>, InferCreationAttributes<Enrollment>> {
declare studentId: number;
declare courseId: number;
}
Student.init({ id: { type: DataTypes.INTEGER.UNSIGNED, autoIncrement: true, primaryKey: true }, name: DataTypes.STRING }, { sequelize, tableName: 'students' });
Course.init({ id: { type: DataTypes.INTEGER.UNSIGNED, autoIncrement: true, primaryKey: true }, title: DataTypes.STRING }, { sequelize, tableName: 'courses' });
Enrollment.init({
studentId: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true },
courseId: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true }
}, { sequelize, tableName: 'enrollments', timestamps: false });
// 关键:两边声明,through 指向同一个模型
Student.belongsToMany(Course, { through: Enrollment, foreignKey: 'studentId', otherKey: 'courseId', as: 'courses' });
Course.belongsToMany(Student, { through: Enrollment, foreignKey: 'courseId', otherKey: 'studentId', as: 'students' });几个要点需要展开说明。首先是 foreignKey 和 otherKey 的区别:foreignKey 指中间表中指向当前模型(调用方)的列,otherKey 指向对方模型的列。两边的声明中这两个值恰好互换,写反了查询结果会错乱。其次是中间表建议显式建模并注册到 sequelize 实例上,虽然 through 也可以传一个字符串让 Sequelize 自动建表,但隐式创建的表无法扩展额外字段(比如选课时间、成绩),后续维护也不方便。
另外,中间表通常设置 timestamps: false,因为它只承载两个外键,不需要 created_at、updated_at 这类时间戳列。当然,如果业务上确实需要记录关联建立的时间,可以保留时间戳,甚至在中间表上加业务字段,使用时通过 include 嵌套拿到。
配置好之后,多对多的查询和写入都很直观:
// 查询学生及其所有课程,同时带出中间表字段
const stu = await Student.findOne({
where: { name: '张三' },
include: [{ model: Course, as: 'courses', through: { attributes: [] } }]
});
// 给学生添加一门课,自动写入中间表
const course = await Course.create({ title: '高等数学' });
await (stu as Student).$add('courses', course);上面 through: { attributes: [] } 表示不返回中间表的字段,如果不加这一句,结果里每门课程对象会多出一个 Enrollment 属性存放中间表数据。这是版本 6 的默认行为,很多人第一次看到会以为数据重复了,其实是中间表数据被一起带出来了。
TypeScript 环境下的常见报错与排查
第一类问题是类型报错,提示 include 的模型不接受某个别名。这几乎都是因为声明关联时用了 as,而查询时直接传模型没带别名,或者反过来。解决办法很简单:两边保持一致,或者在类型工具 InferAttributes 的帮助下,让关联属性的 declare 与别名对应。
第二类问题是运行时外键列不存在。典型场景是数据库表由迁移脚本或 DBA 手工创建,而模型定义里的外键名与实际列名不一致。Sequelize 的 sync 只在自动建表时生效,对已存在的表不会补列,所以必须人工核对 foreignKey 与实际列名。建议在开发阶段开启 sequelize.authenticate() 加日志模式,把生成的 SQL 打印出来比对。
第三类问题是循环依赖。模型文件之间互相 import 很容易形成环,导致某个模型在执行 belongsToMany 时还是 undefined。推荐的做法是把所有关联声明集中放到一个单独的文件里,模型文件只负责定义,最后统一导入执行,这样依赖关系清晰,也方便排查。
// associations.ts —— 集中管理所有关联
import './models/student';
import './models/course';
import './models/enrollment';
import { Student } from './models/student';
import { Course } from './models/course';
import { Enrollment } from './models/enrollment';
import { Category } from './models/category';
import { Post } from './models/post';
Student.belongsToMany(Course, { through: Enrollment, foreignKey: 'studentId', otherKey: 'courseId', as: 'courses' });
Course.belongsToMany(Student, { through: Enrollment, foreignKey: 'courseId', otherKey: 'studentId', as: 'students' });
Category.hasMany(Post, { foreignKey: 'categoryId', as: 'posts' });
Post.belongsTo(Category, { foreignKey: 'categoryId', as: 'category' });
export function setupAssociations() {
// 供入口文件调用,确保关联在模型加载完成后注册
}总结一下配置关联的三条铁律:关联必须成对声明;foreignKey 名称要与实际表列严格一致;多对多两边共用同一个 through 模型且外键互为镜像。做到这三点,配合 underscored 命名策略和集中的关联注册文件,TypeScript 项目里的 Sequelize 关联基本不会出问题。
TypeScriptSequelize关联关系belongsToMany修改时间:2026-09-13 01:16:41