03 类型标注、as 与 satisfies:检查和断言不要混用
对应项目:customerListAttributes。本章会解释它解决了什么,也解释它没有解决什么。
1. 三种写法的核心区别
type Status = "pending" | "active" | "banned";
const annotated: Status[] = ["pending", "active"];
const asserted = ["pending", "active"] as Status[];
const checked = ["pending", "active"] satisfies Status[];
| 写法 | 你在表达什么? | 主要用途 |
|---|---|---|
变量: 类型 |
该变量按目标契约使用,赋值必须满足它 | 公共边界、可变配置、函数参数 |
表达式 as 类型 |
请按这个类型看待值 | TS 推断能力边界,需另有依据 |
表达式 satisfies 类型 |
检查兼容性,并保留该表达式有用的推断 | 配置、字段清单、映射 |
三者都不是运行时验证。as const 是 const 断言,有专门的字面量推断规则,不能与任意 as SomeType 完全混为一谈。
2. 标注宽契约,与保留实际元素集合
在当前安装的 TS 5.9.3 中,上例通常得到:
// annotated:Status[]
// checked:("pending" | "active")[]
annotated.push("banned"); // 合法,变量契约允许全部 Status
checked.push("banned"); // 错误,本次推断元素范围只有 pending | active
satisfies 不会强制变量类型等于右侧目标类型。也不能简单说“satisfies 完全不影响推断”:它的目标提供上下文,可能保留/改变一些具体推断结果。
例如:
const ordinary = { active: true }; // active 通常是 boolean
const checked = { active: true } satisfies { active: boolean };
// 当前 TS 中 active 可被保留为 true,后续赋 false 可能报错。
如果对象以后需要修改 active,明确标注 { active: boolean } 反而更符合意图。
3. as 为什么可能隐藏错误?
interface CustomerInput { first_name: string }
const parsed: unknown = JSON.parse('{"first_name":123}');
const input = parsed as CustomerInput;
input.first_name.toUpperCase(); // 编译可能通过,运行时不是字符串
as 不检查 JSON。通过 unknown 绕过不兼容关系的双重断言尤其容易掩盖错误。
正确边界是检查字段真实形状,或使用项目已经配置好的请求验证链。satisfies 用于编译期已知表达式,也不能替代这类运行时检查。
4. 逐层读懂 Customer 字段数组
原写法:
import type { Attributes } from "sequelize";
import type Customer from "../../models/customer";
const customerListAttributes = [
"customer_id",
"store_id",
"first_name",
"last_name",
"email",
"active",
"create_date",
] satisfies Array<keyof Attributes<Customer>>;
A. Customer
在这个类型位置,Customer 表示模型实例类型,不是 Customer.findAll 这类静态方法所在的类值。
B. Attributes<Customer>
Sequelize 提供的类型工具,获取 Customer 的模型字段类型。对当前 InferAttributes 定义,关联通过 NonAttribute 排除,实例方法也不会被当成普通列。
可以把结果理解成类似:
{
customer_id: number;
store_id: number;
first_name: string;
// 还有 email、active、create_date 等字段
}
这是方便理解的近似,实际类型还可能带 Sequelize 的标记信息。
C. keyof Attributes<Customer>
取字段名联合,类似:
"customer_id" | "store_id" | "first_name" | "last_name" | ...
上面是类型含义示意,省略号不是可编译联合类型。
D. Array<…>
要求数组每一个元素都属于合法字段名。Array<T> 与 T[] 基本是两种数组类型写法。
E. satisfies
检查这个清单是否满足要求;拼成 frist_name 就报错。
这个表达式整体的意思是:这是一份运行时字段清单,编译时检查里面每个名字都属于 Customer 模型字段。
5. 它能复用什么,不能复用什么?
能复用:
- Model 已定义的合法列名范围,无须再手写一遍该范围。
- 一份字段数组,给列表、基础详情和 reload 共用。
- 从该数组派生已选择列名的类型。
不能自动做到:
- 从 interface 在运行时生成数组。
- 检查某 DTO 必需字段是否全部选择。
- 确认真实 MySQL 表存在该列。
- 把 Date 转成 ISO、把 active 数字转成 boolean。
- 让 findAll 的 Model 返回类型自动缩窄到这一组列。
所以这不是“消除所有字段重复”的工具,而是让某类重复有可靠来源,并防止拼写漂移。
6. 如何派生本次选择的字段类型?
沿用上面的 customerListAttributes:
type SelectedKey = (typeof customerListAttributes)[number];
type SelectedCustomerFields = Pick<Attributes<Customer>, SelectedKey>;
这是编译期投影类型。它本身不保证 SQL 实际返回完整字段,更不会调用查询;具体用途见 04、10。
如果 DTO 有转换,仍然应该独立定义 DTO。
7. 和 as const 组合,为什么要注意 readonly?
const selected = ["customer_id", "first_name"] as const satisfies readonly (keyof Attributes<Customer>)[];
这里同时要求固定元组、只读,并检查列名。
Sequelize 当前 attributes 类型通常要求可变数组。实际调用可传浅复制:
await Customer.findAll({ attributes: [...selected] });
实际查询必须普通 import Customer,因为需要运行时类值;前面的 import type 仅用于解释类型构造。
当前 Service 没有给字段数组加 as const:它用 satisfies 检查字段名,保留可变数组,便于直接交给 ORM。这是一种合理选择,不是漏写。
8. 配置映射:检查完整键集合
type Status = "pending" | "active" | "banned";
const status_labels = {
pending: "待处理",
active: "已启用",
banned: "已禁用",
} satisfies Record<Status, string>;
固定对象字面量下漏掉 banned 或拼错键,编译器会提醒;值也必须是 string。
这与 Array<Status> 不一样:数组只限制每个元素,不要求三种状态都出现,也不检查重复。完整性取决于右侧契约,而不是 satisfies 自带“查遗漏”能力。
9. 最佳实践选择
需要可变、统一契约:用显式类型标注。固定字面量集合:考虑 as const。配置要检查合法结构,又想保留具体键/元素信息:考虑 satisfies。
断言只在已有明确事实、但 TS 无法充分表达时使用,并把事实尽量写成检查或注释;不要将来自 HTTP/SQL 的未知数据仅用 as 包一层就当可信。
10. 练习
- 在 customerListAttributes 中故意拼错 first_name,观察错误位置。
- 从清单删掉 email,解释为什么仍可能通过 satisfies,但 mapper 会出问题。
- 给三状态文案映射漏一项,与数组漏一项对照。
- 比较标注 Status[] 与 satisfies Status[] 的 push 行为。
下一章:Pick、Omit 与工具类型。参考:satisfies。