TypeScript 枚举与值类型

最后更新: 2021-02-20 06:14:56 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

封闭集合可能需要编译时约束、运行时查找对象、线路表示,或具备经过验证的行为的领域对象。由于这些需求并不等价,TypeScript 提供了不同的表示方式。

分离类型要求与运行时标识 #

如下类型:

ts
type Status = "pending" | "running" | "done";

仅存在于类型检查期间。JavaScript 无法枚举 Status、测试未知字符串是否属于它,或为其附加元数据。运行时代码需要对应的值或验证函数。

相比之下,原生 enum 会生成 JavaScript 对象并引入枚举类型。as const 表会生成普通对象,并从中派生类型。类会生成构造函数,并且可以强制执行不变量。应根据程序实际需要的运行时标识进行选择。

理解原生枚举的生成结果 #

数字枚举会分配数字,并生成正向和反向映射:

ts
enum StatusCode {
  Pending,
  Running,
  Done,
}

console.log(StatusCode.Running); // 1
console.log(StatusCode[1]);      // "Running"

生成的对象同时支持从名称查找数字,以及从数字查找名称。字符串枚举只生成从名称到值的属性:

ts
enum Status {
  Pending = "pending",
  Running = "running",
  Done = "done",
}

从 TypeScript 5.0 开始,每个枚举成员都有独立的成员类型,包括从计算值初始化的成员。这支持类型收窄和穷尽式 switch,但不会改变生成的对象。

const enum 要求 TypeScript 内联成员值并擦除枚举:

ts
const enum Direction {
  Left,
  Right,
}

内联可能与 isolated-module 转译器冲突,也可能与这样的库发生冲突:库发布的声明的生命周期长于用于编译消费者的确切枚举值。除非能够控制整个工具链和版本策略,否则应避免跨包边界导出 ambient const enum 声明。

对仅类型集合使用字面量联合 #

字面量联合无需生成枚举机制即可匹配 JSON 形状的 API:

ts
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。联合类型还可以区分携带不同数据的变体:

ts
type Payment =
  | { kind: "card"; brand: string; lastFour: string }
  | { kind: "cash" }
  | { kind: "wire"; iban: string };

在 payment.kind === "card" 之后,编译器会公开 brand 和 lastFour。联合类型会检查已经拥有 Payment 的代码,但不会在运行时验证不受信任的 JSON 值。

从 as const 表派生类型 #

值表可以将运行时验证与编译时联合绑定到同一个定义:

ts
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 检查和集合成员检查为类型谓词提供所需的运行时证明。

当顺序是首要要求时,使用数组:

ts
const statuses = ["pending", "running", "done"] as const;
type Status = (typeof statuses)[number];

当稳定的符号键、元数据查找,或与现有成员访问方式的兼容性是首要要求时,使用对象。

将经过验证的值建模为对象 #

值对象表示一种领域值,其不变量与行为结合在一起。例如,百分比不只是任意数字:

ts
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 生成的对象形状。数字枚举的自动递增值容易因成员重排而失效;反向映射属于实现细节,而不是协议。

在输入边界执行以下步骤:

  1. 解析不受信任的表示。
  2. 验证成员关系或构造值对象。
  3. 在应用程序内部保留经过验证的类型。
  4. 写入 JSON、数据库值或消息时进行有意的转换。

当消费者需要枚举生成的运行时标识,并且工具链接受该输出时,使用原生枚举。对于仅类型集合,使用字面量联合;当代码还需要运行时值时,使用 as const 表;对于特定变体的数据,使用可辨识联合;对于验证和行为,使用值对象。

评论

在 GitHub 上阅读或评论(发表评论需要登录)