你重构了一个 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。这里有一个实用的推进顺序:
- 把
assertNever添加到共享工具函数中。在新的switch代码块里使用它。 - 当你修改现有的基于 union 的
switch时,顺手加上守卫。 - 对于基于 discriminated union 的新代码,如果逻辑是纯函数且每个分支相互独立,优先使用对象映射模式。
- 如果你发现自己多次编写嵌套
switch或复杂的匹配逻辑,可以评估一下ts-pattern。
目标不是消灭 switch 语句,而是让编译器在用户遇到 bug 之前就把它抓住。
如果你想亲眼看看效果,试着从 AreaMap 示例中删掉一个处理函数,然后看着 tsc 报错。那条红色波浪线就是平静周五部署和周末 PagerDuty 告警之间的区别。