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. 它能复用什么,不能复用什么?

能复用:

不能自动做到:

所以这不是“消除所有字段重复”的工具,而是让某类重复有可靠来源,并防止拼写漂移。

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. 练习

  1. 在 customerListAttributes 中故意拼错 first_name,观察错误位置。
  2. 从清单删掉 email,解释为什么仍可能通过 satisfies,但 mapper 会出问题。
  3. 给三状态文案映射漏一项,与数组漏一项对照。
  4. 比较标注 Status[] 与 satisfies Status[] 的 push 行为。

下一章:Pick、Omit 与工具类型。参考:satisfies。