03|TypeScript 模型定义:从 SQL 到 Model

这一章围绕当前 admin_user 展开。目标不是复制模板,而是理解每个 SQL 字段为什么对应某个 Sequelize 类型和 TypeScript 类型。

1. 三种“类型”不是一回事

以管理员 ID 为例:

id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY

Model 配置:

id: {
  type: DataTypes.BIGINT.UNSIGNED,
  primaryKey: true,
  autoIncrement: true,
}

TypeScript 属性:

declare id: CreationOptional<string>;

它们分别解决:

不能只维护其中一个。

2. 泛型声明逐段解释

当前模型:

export class AdminUser extends Model<
  InferAttributes<AdminUser>,
  InferCreationAttributes<AdminUser>
> {
  // 字段声明
}

Model 的两个主要泛型可以近似理解为:

Model<查询后完整属性, 创建时允许输入的属性>

InferAttributes<AdminUser>

根据类中的 declare 属性推导查询结果的字段集合,避免手写一份 AdminUserAttributes 后又与类字段不同步。

InferCreationAttributes<AdminUser>

推导调用 AdminUser.create() 时需要的字段。被 CreationOptional<T> 标记的字段,在创建输入中可以省略。

这两个类型只存在于编译阶段,不生成运行时代码。

3. declare 为什么重要

declare username: string;

declare 告诉 TypeScript:“实例运行时会有这个属性,请按该类型检查”,但不会在编译后的 JavaScript 中初始化字段。

这适合 Sequelize,因为实例属性由 Sequelize 根据查询结果和内部机制注入。

不建议写成:

username!: string;

在某些编译配置下,类字段初始化可能覆盖 Sequelize 的访问器。Sequelize v6 的 TypeScript 用法通常采用 declare。

4. CreationOptional<T> 到底是什么

declare id: CreationOptional<string>;
declare token_version: CreationOptional<number>;

含义是:实例读取时字段存在,但创建时可以省略,因为数据库或 Model 有生成/默认机制。

例如:

await AdminUser.create({
  username: "admin",
  password_hash: "...",
  display_name: "Administrator",
  email: null,
  locked_until: null,
  last_login_at: null,
});

可以省略自动递增 id 和有默认值的 token_version。

它不等于把实例属性写成 string | undefined。成功保存并读回的实例应把 id 当作字符串使用。

与 exactOptionalPropertyTypes 的关系

当前项目开启:

"exactOptionalPropertyTypes": true

exactOptionalPropertyTypes 不会自动删除第三方类型中明确写出的 undefined。在 Sequelize v6 中,CreationOptional<T> 经 InferCreationAttributes 推导后,创建输入的键可以省略,类型也允许显式传入 undefined;但模型实例上的属性仍然是 T,不应因此写成 T | undefined。

// CreationOptional 字段推荐直接省略,让数据库或 Model 默认机制负责
await SomeModel.create({ required_value: "example" });

// 即使某些 CreationOptional 字段在 Sequelize v6 类型中允许 undefined,
// 也不要用 undefined 冒充数据库 NULL。

数据库 NULL 应明确表达为 null。当前 AdminUser.email 是 string | null,但没有标记 CreationOptional,所以创建管理员时必须传字符串或 null;email: undefined 会在当前严格类型下报错。不要把某一个字段的结论推广到所有 Sequelize 创建属性。

5. NULL、可省略、默认值要分开

允许 NULL

email VARCHAR(254) DEFAULT NULL
declare email: string | null;

email: {
  type: DataTypes.STRING(254),
  allowNull: true,
}

读取时 email 必然是字符串或 null,所以业务代码必须处理两种情况。

创建时可省略

创建输入是否可省略,由 CreationOptional 表达。当前 email 没标记它,因此类型要求调用者明确传 string 或 null。这是一个合理的显式边界,但也可以根据团队习惯把有数据库默认值的列标为创建可选。

数据库默认值

token_version INT UNSIGNED NOT NULL DEFAULT 0

数据库默认值才是其他写入者也能得到的最终保证。Model 中的 defaultValue 还可让 Sequelize 构建实例时知道默认值,两者最好保持一致。

6. 当前字段逐类映射

字符串

username VARCHAR(50) NOT NULL
password_hash VARCHAR(255) NOT NULL
declare username: string;
declare password_hash: string;

type: DataTypes.STRING(50)
type: DataTypes.STRING(255)

TypeScript 的 string 不包含长度信息。最大长度由请求校验、Model 校验或数据库负责。

BIGINT 主键

declare id: CreationOptional<string>;

即使小 ID 看起来像 1,也不要临时改成 number。类型应覆盖表的完整取值范围。

状态值

export enum AdminAccountStatus {
  Disabled = 0,
  Active = 1,
  Locked = 2,
}
declare account_status: CreationOptional<AdminAccountStatus>;

数据库保存数字,TypeScript enum 赋予数字业务名称。数据库 CHECK 与 Model isIn 仍要阻止非法数字;enum 本身不是运行时数据库约束。

普通整数

declare token_version: CreationOptional<number>;
declare failed_login_count: CreationOptional<number>;

MySQL INT UNSIGNED 最大值小于 JavaScript 安全整数范围,使用 number 没有 BIGINT 精度问题。

日期时间

declare locked_until: Date | null;
declare created_at: CreationOptional<Date>;

Sequelize 通常把 MySQL DATETIME 映射成 JavaScript Date。对外 DTO 再转换为稳定字符串:

created_at: admin.created_at.toISOString()

必须明确数据库、Sequelize 和 API 使用的时区策略。Date 表示时间点,但 DATETIME 本身不携带时区标识。完整的配置、迁移和测试方法见时区与时间字段。

7. 从 SQL 设计一个新模型

假设后续增加登录审计表:

CREATE TABLE admin_login_audit (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  admin_user_id BIGINT UNSIGNED NULL,
  attempted_username VARCHAR(50) NOT NULL,
  login_result TINYINT UNSIGNED NOT NULL,
  ip_address VARCHAR(45) NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  CONSTRAINT chk_admin_login_audit_result
    CHECK (login_result IN (1, 2, 3, 4)),
  CONSTRAINT fk_audit_admin
    FOREIGN KEY (admin_user_id) REFERENCES admin_user(id)
    ON DELETE SET NULL
);

基础类型骨架:

import {
  CreationOptional,
  DataTypes,
  ForeignKey,
  InferAttributes,
  InferCreationAttributes,
  Model,
  NonAttribute,
} from "sequelize";

import type AdminUser from "./admin_user";

export class AdminLoginAudit extends Model<
  InferAttributes<AdminLoginAudit>,
  InferCreationAttributes<AdminLoginAudit>
> {
  declare id: CreationOptional<string>;
  declare admin_user_id: ForeignKey<AdminUser["id"]> | null;
  declare attempted_username: string;
  declare login_result: number;
  declare ip_address: string | null;
  declare created_at: CreationOptional<Date>;
}

ForeignKey<AdminUser["id"]> 同时表达:这是外键,并复用管理员 ID 的 string 类型。数据库中仍必须真正创建 FOREIGN KEY。

8. NonAttribute:关联属性不是列

如果管理员实例上声明关联结果:

declare login_audits?: NonAttribute<AdminLoginAudit[]>;

NonAttribute 告诉 InferAttributes:它不是 admin_user 的普通列,不应出现在 create() 输入或 Model.init() 字段中。

问号表示只有查询使用对应 include 时,关联结果才会被加载。不要把“尚未加载”误认为“数据库中没有记录”。

9. TypeScript 无法替你做的事

以下代码可通过类型检查:

const admin = await AdminUser.findByPk("1");

运行时仍可能:

因此还需要 Migration、数据库约束、错误处理和集成测试。

10. 初学者常见错误

把可空写成可选

declare email?: string; // 不等于数据库 NULL

undefined 常表示属性没有提供或没加载;SQL NULL 应用 null 表达。

给 BIGINT 使用 number

小数据测试不会暴露问题,但达到安全整数边界后会静默损坏 ID。

所有字段都标 CreationOptional

这样 create({}) 可能通过类型检查,把本该由调用方提供的字段推迟成运行时数据库错误。

SQL 有默认值,Model 和类型却忘记同步

会出现运行时可创建、TypeScript 却要求传值,或 Model 使用另一个默认值的混乱。

用 any 解决关联类型

这会失去字段拼写、NULL 和是否加载的检查。应使用 ForeignKey、NonAttribute 和明确的 include 类型。

以为 enum 自动创建数据库约束

TypeScript enum 编译后只是 JavaScript 对象;数据库仍需要 CHECK 或适当字段约束。

11. 动手练习

  1. 为 admin_user 每个字段写出“MySQL—DataTypes—TypeScript”三列表。
  2. 删除练习分支中 email 的 | null,观察哪些代码仍能编译,以及为什么这不能证明数据库没有 NULL。
  3. 为上面的 admin_login_audit 补完 Model.init(),保持字段 snake_case。
  4. 故意把 admin_user_id 写成 number,解释为什么小 ID 测试可能通过。
  5. 写两个创建输入:一个依赖数据库默认值,一个显式提供可空字段。

12. 复盘问题

  1. InferAttributes 与 InferCreationAttributes 的区别是什么?
  2. CreationOptional<T> 为什么不等于 T | undefined?
  3. declare 为什么适合 Sequelize Model?
  4. SQL NULL、TypeScript 可选属性、数据库默认值有什么区别?
  5. 为什么 DataTypes.BIGINT 在本项目对应 string?
  6. ForeignKey 和 NonAttribute 分别解决什么类型问题?

下一章继续拆解 Model.init():字段映射、时间戳、Scope 与索引分别应该放在哪里。