导读:本期聚焦于小伙伴创作的《如何用TypeScript为Sequelize v7的关联预加载选项封装类型?》,敬请观看详情。当你在 Sequelize v7 里调用 findAll 并传入 include 来加载关联数据时,会发现类型系统并不能自动推断出返回结果中已包含了哪些关联模型。这种期待与现实的落差往往导致写一堆 any 或者手动维护重复的接口,维护成本高且容易出错。本文从实际开发中的痛点出发,探讨如何利用 TypeScript 的泛型工具与类型推断,对 include 选项进行二次封装,让返回结果的类型能够精确地反映预加载的关联结构。我们会一步步拆解 Sequelize v7 的 Model 泛型参数,理解它如何处理关联,然后给出一个可复用的 Include 类型工厂,支持单层与嵌套关联,并讨论如何处理多态关联与作用域别名。读完本文后,你将能构建一套贴合项目实际数据模型的类型安全查询层,告别类型断言带来的隐患。

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

如何用TypeScript为Sequelize v7的关联预加载选项封装类型?

究其根本,Sequelize v7 的模型类型虽然强大,但 findAll 这类方法的返回类型是基于模型本身的静态泛型来推导的。官方并没有提供一个内置的方式去根据 include 的内容动态变换输出类型,因为这涉及到非常复杂的 TypeScript 类型体操,且不同项目的关联配置千差万别。所以,我们需要自己动手封装一层类型安全的查询工具,让 TypeScript 能够“知道”你的查询包含了哪些关联,并据此生成精确的结果类型。

Sequelize v7 中 include 选项的类型困境

在 Sequelize v7 中定义一个模型时,通常会使用 Model.init 配合 InferAttributesInferCreationAttributes 来自动推导字段类型。例如,一个简单的 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

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。