封闭集合可能需要编译时约束、运行时查找对象、线路表示,或具备经过验证的行为的领域对象。由于这些需求并不等价,TypeScript 提供了不同的表示方式。
分离类型要求与运行时标识 #
如下类型:
type Status = "pending" | "running" | "done";仅存在于类型检查期间。JavaScript 无法枚举 Status、测试未知字符串是否属于它,或为其附加元数据。运行时代码需要对应的值或验证函数。
相比之下,原生 enum 会生成 JavaScript 对象并引入枚举类型。as const 表会生成普通对象,并从中派生类型。类会生成构造函数,并且可以强制执行不变量。应根据程序实际需要的运行时标识进行选择。
理解原生枚举的生成结果 #
数字枚举会分配数字,并生成正向和反向映射:
enum StatusCode {
Pending,
Running,
Done,
}
console.log(StatusCode.Running); // 1
console.log(StatusCode[1]); // "Running"
生成的对象同时支持从名称查找数字,以及从数字查找名称。字符串枚举只生成从名称到值的属性:
enum Status {
Pending = "pending",
Running = "running",
Done = "done",
}从 TypeScript 5.0 开始,每个枚举成员都有独立的成员类型,包括从计算值初始化的成员。这支持类型收窄和穷尽式 switch,但不会改变生成的对象。
const enum 要求 TypeScript 内联成员值并擦除枚举:
const enum Direction {
Left,
Right,
}内联可能与 isolated-module 转译器冲突,也可能与这样的库发生冲突:库发布的声明的生命周期长于用于编译消费者的确切枚举值。除非能够控制整个工具链和版本策略,否则应避免跨包边界导出 ambient const enum 声明。
对仅类型集合使用字面量联合 #
字面量联合无需生成枚举机制即可匹配 JSON 形状的 API:
type Status = "pending" | "running" | "done";
function label(status: Status): string {
switch (status) {
case "pending":
return "Pending";
case "running":
return "Running";
case "done":
return "Done";
}
}控制流分析会在每个分支中收窄 status。联合类型还可以区分携带不同数据的变体:
type Payment =
| { kind: "card"; brand: string; lastFour: string }
| { kind: "cash" }
| { kind: "wire"; iban: string };在 payment.kind === "card" 之后,编译器会公开 brand 和 lastFour。联合类型会检查已经拥有 Payment 的代码,但不会在运行时验证不受信任的 JSON 值。
从 as const 表派生类型
#
值表可以将运行时验证与编译时联合绑定到同一个定义:
const Status = {
Pending: "pending",
Running: "running",
Done: "done",
} as const satisfies Record<string, string>;
type StatusName = keyof typeof Status;
type Status = (typeof Status)[StatusName];
const statuses: ReadonlySet<string> = new Set(Object.values(Status));
function isStatus(value: unknown): value is Status {
return typeof value === "string" && statuses.has(value);
}as const 可防止属性值扩大为 string。satisfies 会检查表的形状,同时保留这些字面量类型。只读集合有意接受任意字符串用于查找;typeof 检查和集合成员检查为类型谓词提供所需的运行时证明。
当顺序是首要要求时,使用数组:
const statuses = ["pending", "running", "done"] as const;
type Status = (typeof statuses)[number];当稳定的符号键、元数据查找,或与现有成员访问方式的兼容性是首要要求时,使用对象。
将经过验证的值建模为对象 #
值对象表示一种领域值,其不变量与行为结合在一起。例如,百分比不只是任意数字:
class Percentage {
private constructor(readonly value: number) {}
static from(value: number): Percentage {
if (!Number.isFinite(value) || value < 0 || value > 100) {
throw new RangeError("percentage must be finite and between 0 and 100");
}
return new Percentage(value);
}
asRatio(): number {
return this.value / 100;
}
equals(other: Percentage): boolean {
return this.value === other.value;
}
toJSON(): number {
return this.value;
}
}私有构造函数强制调用方经过验证。方法为领域操作命名,toJSON 定义边界表示。由于 JSON 解析返回的是数字而不是类实例,反序列化必须再次调用 Percentage.from。
不要仅为了包装标签而使用类。当对象保护不变量、提供行为,或防止结构相似的基本类型相互混用时,分配和转换代码才是合理的。
明确设计序列化边界 #
线路格式应使用稳定且有文档说明的字符串或数字,而不是依赖 TypeScript 生成的对象形状。数字枚举的自动递增值容易因成员重排而失效;反向映射属于实现细节,而不是协议。
在输入边界执行以下步骤:
- 解析不受信任的表示。
- 验证成员关系或构造值对象。
- 在应用程序内部保留经过验证的类型。
- 写入 JSON、数据库值或消息时进行有意的转换。
当消费者需要枚举生成的运行时标识,并且工具链接受该输出时,使用原生枚举。对于仅类型集合,使用字面量联合;当代码还需要运行时值时,使用 as const 表;对于特定变体的数据,使用可辨识联合;对于验证和行为,使用值对象。
评论
在 GitHub 上阅读或评论(发表评论需要登录)