你重构了一个 shape 类型,加了一个新变体,TypeScript 却一切正常。CI 通过了。部署也上线了。然后某个用户触发了一个返回 undefined 的运行时分支,你的应用在生产环境抛出了异常。

罪魁祸首几乎肯定是 switch 语句。TypeScript 的 switch 不提供穷尽性保证。给 union 增加一个新成员,所有基于该 union 的 switch 都会悄无声息地变得不完整。编译器不会阻止你,甚至不会警告你。这正是让人栽跟头的地方:TypeScript 知道所有可能的情况,但 switch 语句不参与穷尽性检查。

穷尽性检查到底是什么意思

穷尽性意味着编译器可以证明每一个可能的值都已被处理。像 Rust 和 OCaml 这样的语言会在编译时强制执行这一点。TypeScript 不会——至少对 switch 语句不会。

来看一个 discriminated union:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle is missing — and TypeScript is fine with it
  }
}

这段代码能编译通过。当 shape.kind === "triangle" 时,它也会返回 undefined。函数签名承诺返回 number,但实现并未兑现。TypeScript 不会标出缺失的分支,因为 switch 代码块不会被检查是否完整。

对象查找模式

最简单的替代方案就是一个普通对象映射。每个键对应一个处理函数。你用判别式(discriminant)作为索引去访问对象,编译器会检查该键是否存在。

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

这样好多了。如果你给 Shape 增加 "polygon",TypeScript 会抱怨 areaHandlers 缺少一个键。错误指向正确的位置,而且发生在编译时。

但这些类型强制(casts)很丑陋。我们可以做得更好。

无需类型强制的类型安全处理函数

诀窍在于将每个变体映射到一个直接接收收窄后类型的函数。你使用 mapped type 来保持键与特定 shape 变体之间的关系。

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

现在每个处理函数都接收一个正确收窄的参数。函数内部无需手动类型强制。最后一行的 as never 是个小瑕疵,但它是局部的、安全的。真正的价值在于 areaHandlers 必须包含 ShapeKind 中的每一个键。删掉一个,或者给 Shape 增加一个新变体,构建就会失败。

用 never 断言实现穷尽性检查

有些团队喜欢保留 switch,但加上编译时的穷尽性守卫。这是一种务实的折中方案。你添加一个接收 never 的 default 分支,强制编译器验证所有情况都已被处理。

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

如果你增加了一种新的 shape kind 却忘了添加对应的分支,default 分支就会收到一个非 never 的值。TypeScript 会报错,因为你不能把一个具体类型赋值给 never。错误指向 switch 所在的位置,这正是你想要的地方。

这种模式在 Sentry 和其他大型 TypeScript 代码库中被广泛使用。它不需要改变代码结构。它只是让编译器为你工作,而不是与你作对。

使用第三方库进行模式匹配

如果你想要更接近 Rust 的 match,像 ts-pattern 这样的库提供了内置穷尽性检查的模式匹配。

import { match, P } from "ts-pattern";

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

.exhaustive() 调用是关键。如果遗漏了某个变体,TypeScript 会报错。该库还支持嵌套模式、守卫和通配符,可以替代复杂的嵌套 switch 代码块。

这很强大,但它引入了依赖。对于单个 discriminated union,ts-pattern 有些大材小用。对于大量使用模式匹配的代码库,它是值得增加包体积的。

Switch 语句仍然占优的场景

switch 并不总是错的。如果你的分支需要 fall through,或者逻辑是指令式的、带有提前返回和副作用,那么 switch 可能比查找表或一连串 .with() 调用更易读。

问题不在于语法,而在于缺少穷尽性检查。如果你在使用 switch,加上 assertNever 守卫。只需两行代码,就能堵住安全漏洞。

如何在不重写所有代码的情况下采用这些方案

你今天不需要重构所有的 switch。这里有一个实用的推进顺序:

  1. assertNever 添加到共享工具函数中。在新的 switch 代码块里使用它。
  2. 当你修改现有的基于 union 的 switch 时,顺手加上守卫。
  3. 对于基于 discriminated union 的新代码,如果逻辑是纯函数且每个分支相互独立,优先使用对象映射模式。
  4. 如果你发现自己多次编写嵌套 switch 或复杂的匹配逻辑,可以评估一下 ts-pattern

目标不是消灭 switch 语句,而是让编译器在用户遇到 bug 之前就把它抓住。

如果你想亲眼看看效果,试着从 AreaMap 示例中删掉一个处理函数,然后看着 tsc 报错。那条红色波浪线就是平静周五部署和周末 PagerDuty 告警之间的区别。