Kodokon kodokon.com

never、穷尽性与进阶可辨识联合类型

你将用可辨识联合类型为你的状态建模,并用 never 类型锁定它们的穷尽性。

10 分钟 · 3 题

在 Kodokon 中打开本课

never 是空类型:没有任何值属于它。当编译器证明某条代码路径不可能到达时,它就会出现——例如在穷尽了一个联合类型的每个成员之后。这一特性使它成为一种设计工具:如果你能让编译器在 switch 的默认分支中说出 never,你就得到了每个情况都被处理的静态证明。这正是穷尽性检查(exhaustiveness checking)的基础。

你在上一模块中已经初识可辨识联合类型;现在是时候把它变成一件真正的设计工具了。相比一个塞满可选字段的单一接口,可辨识联合类型让不可能的状态无法被表示:你不可能同时拥有 dataerror,也不可能有一个没有数据的成功状态。它是加载状态、操作结果和事件消息的首选模式。

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "Waiting";
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data 只存在于 success 变体中:在其他任何地方都无法访问。

你已经见过在 default 分支中调用的 assertNever 守卫;现在让我们精确剖析它是如何工作的。只要每个情况都被覆盖,它的参数在此处就是 never 类型,一切都能编译通过。若你向联合类型添加一个变体却不更新 switch,那个被遗漏的变体就不再能赋值给 never:出现编译期错误,且错误信息中会带上缺失情况的名称。你由此把一个生产环境的 bug 变成了一次被拒绝的构建。

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
向 Shape 添加一个变体会破坏 area 的编译。

有两个进阶细节值得你留意。其一,判别式的收窄只有在你直接测试外层对象时,才会传播到嵌套结构中;单独解构判别式会切断这种关联(除了自 TypeScript 4.6 起对联合解构所支持的情形)。其二,never 在联合类型中会被吸收(string | never 就是 string):这正是条件类型能借以过滤掉某些变体的原因,Exclude 就是这样做的。

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* register the handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract 按判别式过滤联合类型:处理函数因此是精确的。

知识检测

确认你已牢记本课的重点内容。

  1. 对于加载状态,为什么可辨识联合类型比带可选字段的接口更可取?
    • 它在运行时占用更少内存
    • 它让不一致的组合(同时存在 data 和 error)无法被表示
    • 它免去了编写 switch 语句
    • 它在严格模式下是必需的
  2. 如果向 Shape 添加一个变体,却不修改包含 assertNever 的那个 switch,会发生什么?
    • 每次调用 area 都会抛出异常
    • 代码能编译,但新变体会返回 undefined
    • 编译失败,因为被遗漏的变体无法赋值给 never
  3. string | never 是什么类型?
    • never
    • string
    • unknown
    • string | undefined