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>;
它们分别解决:
- MySQL 类型:数据库怎样存储、排序和约束;
DataTypes:Sequelize 怎样映射和生成 SQL;- TypeScript 类型:源码怎样被静态检查。
不能只维护其中一个。
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");
运行时仍可能:
- 连接失败;
- 表或列不存在;
- 数据库权限不足;
- Model 与真实列类型不一致;
- 返回
null; - 数据违反应用假设。
因此还需要 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. 动手练习
- 为
admin_user每个字段写出“MySQL—DataTypes—TypeScript”三列表。 - 删除练习分支中
email的| null,观察哪些代码仍能编译,以及为什么这不能证明数据库没有 NULL。 - 为上面的
admin_login_audit补完Model.init(),保持字段 snake_case。 - 故意把
admin_user_id写成number,解释为什么小 ID 测试可能通过。 - 写两个创建输入:一个依赖数据库默认值,一个显式提供可空字段。
12. 复盘问题
InferAttributes与InferCreationAttributes的区别是什么?CreationOptional<T>为什么不等于T | undefined?declare为什么适合 Sequelize Model?- SQL NULL、TypeScript 可选属性、数据库默认值有什么区别?
- 为什么
DataTypes.BIGINT在本项目对应string? ForeignKey和NonAttribute分别解决什么类型问题?
下一章继续拆解 Model.init():字段映射、时间戳、Scope 与索引分别应该放在哪里。