Sequelize 在版本 7 中全面转向 TypeScript,并重新设计了模型定义方式,让类型推断能力有了明显提升。然而,在实际使用关联查询时,很多开发者会发现一个令人困扰的问题:当我们在 findAll 的 include 选项中加入关联模型后,返回结果的类型并不会自动带上这些被关联的数据。也就是说,你明明已经在查询中请求了 User 附带其 Posts,但 TypeScript 仍然只认 User 本身的属性,.Posts 被视为可能不存在的属性。这使得我们不得不频繁使用类型断言,或者手动扩展接口,一旦关联层次变深,维护成本随之急剧上升。

究其根本,Sequelize v7 的模型类型虽然强大,但 findAll 这类方法的返回类型是基于模型本身的静态泛型来推导的。官方并没有提供一个内置的方式去根据 include 的内容动态变换输出类型,因为这涉及到非常复杂的 TypeScript 类型体操,且不同项目的关联配置千差万别。所以,我们需要自己动手封装一层类型安全的查询工具,让 TypeScript 能够“知道”你的查询包含了哪些关联,并据此生成精确的结果类型。
Sequelize v7 中 include 选项的类型困境
在 Sequelize v7 中定义一个模型时,通常会使用 Model.init 配合 InferAttributes 与 InferCreationAttributes 来自动推导字段类型。例如,一个简单的 Customer 模型可能长这样:
import {
Model, DataTypes, InferAttributes, InferCreationAttributes,
CreationOptional, NonAttribute, HasManyGetAssociationsMixin,
} from 'sequelize';
class Customer extends Model<
InferAttributes<Customer>,
InferCreationAttributes<Customer>
> {
declare id: CreationOptional<number>;
declare name: string;
declare orders?: NonAttribute<Order[]>;
declare getOrders: HasManyGetAssociationsMixin<Order>;
// 其他关联方法...
}
这里的 orders 属性被标记为 NonAttribute<Order[]>,表示它并非数据库字段,而是关联属性,且类型上允许其为可选。当我们执行查询时,如果不使用 include,那么实例上的 orders 就是 undefined,类型系统也会尊重这个可能性。而当我们在查询里主动加入 include: [{ model: Order }] 时,我们期望返回的 Customer 实例中 orders 属性变成一个真实的 Order 数组,但类型系统并不会自动把 NonAttribute<Order[]> 转换成必填的 Order[]。
此外,include 本身的定义来自 Sequelize 的全局类型,比如 FindOptions 中的 include 被推导为一个联合类型,它允许你传入任何模型的关联选项,但无法和具体查询的返回类型产生关联。这就导致了一个很尴尬的局面:代码里精心维护的关联声明,在查询层面无法获得类型回馈。
一个常见的 workaround 是手动定义一个接口,比如 CustomerWithOrders,但这在关联组合非常多的场景下极度不灵活,比如有时需要加载 orders + products,有时需要加载 orders + address,组合数会指数级增长。因此,我们需要一个基于泛型的动态类型映射方案。
利用 TypeScript 泛型封装预加载类型
我们的核心思路是构建一个高阶类型,它接收一个模型类型和一个预加载描述,然后输出一个新的类型,该类型中所有被包含的关联属性将变为必填且正确的类型。这需要借助 TypeScript 的映射类型、条件类型以及递归类型来处理嵌套关联。
首先,我们需要一种方式来表示“本次查询要加载哪些关联”。最简单的是使用一个对象字面量类型,其中每个 key 代表要加载的关联属性名,value 代表该关联是否需要进一步嵌套。例如,对于 Customer 模型,可能的 include 描述可以是:
type IncludeDesc = {
orders?: boolean | { product?: boolean };
// 可以继续扩展
};
这样一来,我们就有了一个关于预加载结构的声明。接下来,我们需要一个泛型工具类型 IncludeType,它把原始的模型类型 M 和这个描述 T 结合起来,产出新的类型。其核心逻辑是:遍历 M 中的关联属性,如果该属性的 key 存在于 T 中,则将其映射为加载后的类型(即原关联属性的 target 类型);如果 T 中该 key 的值又是一个对象,则递归地对该 target 类型应用同样的逻辑。
这里需要 Sequelize v7 模型能够暴露一些元信息。幸运的是,通过 InferAttributes 推导出来的模型类型已经包含了所有关联属性,只是它们都被标记为 NonAttribute。我们可以写一个工具类型来剥离 NonAttribute 包装并获取内部真实类型:
// 移除 NonAttribute 包装,获取内部类型 type UnwrapNonAttribute<T> = T extends NonAttribute<infer U> ? U : T;
然后,定义一个映射类型 IncludeType<M, T>:
type IncludeType<
M extends Record<string, any>,
T extends Record<string, boolean | Record<string, any> | undefined>
> = {
[K in keyof M]:
K extends keyof T
? NonNullable<UnwrapNonAttribute<M[K]>> extends infer U
? T[K] extends Record<string, any>
? IncludeType<U, T[K]> // 递归处理嵌套关联
: U
: never
: M[K];
};
这个类型对每个属性 K 进行判断:如果 K 在 include 描述 T 中,那么先剥掉 NonAttribute 得到目标类型 U,如果 T[K] 是一个对象(表示还有更深层的 include),则递归地调用 IncludeType<U, T[K]>,否则直接返回 U。注意我们还用了 NonNullable 来保证原本可能是 undefined 的可选属性变为非可选,因为既然已经预加载了,它就应该存在。
有了这个类型之后,我们可以封装一个 findAllTyped 函数:
async function findAllTyped<
M extends Model,
T extends Record<string, boolean | Record<string, any> | undefined>
>(
model: { new (): M },
includeDesc: T,
options?: Omit<FindOptions, 'include'>
): Promise<IncludeType<InstanceType<M>, T>[]> {
// 将 includeDesc 转换成 Sequelize 可识别的 include 数组
const include = Object.entries(includeDesc).map(([key, value]) => {
const association = model.associations?.[key];
if (!association) throw new Error(`Association ${key} not found`);
const inc: any = { model: association.target };
if (typeof value === 'object') {
inc.include = Object.entries(value).map(([nk, nv]) => ({
model: association.target.associations?.[nk]?.target,
}));
}
return inc;
});
return model.findAll({ ...options, include } as any);
}
实际使用时,调用 findAllTyped(Customer, { orders: true }) 后,返回的数组元素的类型就会自动拥有一个非可选的 orders: Order[] 属性,类型安全就此达成。
进阶技巧:处理深层嵌套和多态关联
前述方案已经可以处理一层关联以及常规的深层嵌套,但现实项目中往往还涉及更复杂的情况,比如多态关联(通过 as 别名定义的关联)、或者在同一个模型上对同一个目标模型存在多个关联(例如 User 有 createdOrders 和 assignedOrders)。这些场景需要我们进一步完善类型封装。
多态关联在 Sequelize v7 中通过 as 属性区分。在模型的类型定义里,这些关联属性会以 as 指定的名字出现。因此,我们的 include 描述依然可以用关联属性名来作为 key,而无需特别处理别名。但关键是,当我们根据 key 去查找关联元信息时,必须能够匹配到正确的 as。在 findAllTyped 的实现中,我们通过 model.associations?.[key] 来获取关联配置,这里 key 就是属性的实际名称,所以天然支持别名。
对于深层嵌套,如果 include 描述里的某个分支是一个对象,我们需要递归地调用 IncludeType,这一点在前文的类型定义中已经通过 T[K] extends Record<string, any> 判断分支完成了。递归深度理论上没有限制,但实际使用中应当注意 TypeScript 的递归深度限制(通常 50 层),不过对关联查询来说这早已足够。
另一个进阶需求是支持条件关联,即 include 里还可以传 where 来过滤关联数据。这会让返回类型产生变化:部分关联实例可能被过滤掉,但类型上仍然是一个数组。除非我们需要更精细的依赖查询条件的类型(比如保证数组非空),否则我们的封装已经足够。如果确实需要映射 where 条件对数据形态的影响,那将进入完全类型安全的查询构建器领域,复杂度会急剧上升,一般建议在应用层通过 as const 断言或者依赖业务守卫函数来处理。
最后,为了让开发体验更顺畅,我们可以将 findAllTyped 进一步封装成一个与该模型绑定 factory 函数:
function createTypedFinder<M extends Model>(model: { new (): M }) {
return <T extends Record<string, boolean | Record<string, any> | undefined>>(
includeDesc: T,
options?: Omit<FindOptions, 'include'>
) => findAllTyped(model, includeDesc, options);
}
const findCustomers = createTypedFinder(Customer);
const customers = await findCustomers({ orders: { product: true } });
// customers 的类型自动推导,且 orders 中含有 product
通过这种封装,我们彻底消除了手动维护查询返回类型的心智负担,同时完整保留了数据模型层的类型信息。当关联结构发生变化时,类型检查也会立刻提示调用方,确保查询行为始终与数据库结构保持一致。
TypeScriptSequelize_v7预加载选项修改时间:2026-08-12 18:46:21