DrillLab
第 104 / 105 道104 / 105 · DrillLab 自出By DrillLab

判别联合怎么配合 switch 做穷尽检查

How do discriminated unions enable exhaustiveness checking

先自己答,再往下看Answer it yourself first

一句话:判别联合(discriminated union, 也译可辨识联合)指每个成员都带同一个字段、 且字段类型是互不相同的字面量。switch 这个字段, 每个 case 里编译器自动收窄;default里把值赋给 never,将来加了成员忘了处理, 编译直接失败 —— 这就是穷尽检查(exhaustiveness check)。

穷尽检查的机制:控制流走到 default 时, 编译器算出「还没被 case 排除的成员」。三个成员都处理过, 剩下的是 never,const exhausted: never = s 成立; 第四个成员加进来却没写 case,剩下的就不是 never, 这一行立刻报错。错误出现在「忘了改的那处代码」, 而不是上线之后。

对比 as 断言:收窄的每一步都对应一个 真实的运行时检查,是「证明」;as不产生任何运行时代码,是「让编译器闭嘴」。JSON.parse(...) as Shape 能过编译, 但值长什么样由运行时说了算, 错误被推迟到离出错点很远的地方爆发。 as 有正当用途(类型守卫内部、测试代码、DOM 查询的细化), 但拿它替代收窄,等于手动关掉编译期的保障。

会追问:「typeof 收窄为什么不够用?」—— typeof 对一切对象都返回 "object", 分不开对象联合的成员;判别联合就是给对象联合准备的收窄手段。 本站 foundations 的 TS 课里 SettledResultstatus 收窄就是同一招,这一题只是往深处多走一层。 「判别字段为什么必须是字面量类型?」—— 两个成员的字段如果都是 string,编译器无从区分。 还会问 assertNever:把 default 里那两行抽成function assertNever(x: never): never, 每个 switch 复用。

In one line: a discriminated union is one where every member carries the same field, each typed as a different literal. switch on that field and the compiler narrows automatically in every case; in default, assign the value to never, and if a member is added later without a case, compilation fails on the spot — that is exhaustiveness checking.

How the check works: when control flow reaches default, the compiler computes which members the cases have not eliminated. With all three handled, what remains is never, so const exhausted: never = s holds; add a fourth member without a case and what remains is no longer never, so that line errors immediately. The failure shows up at the code you forgot to change — not in production.

Contrast with as: every narrowing step corresponds to a real runtime check — it is proof. as emits no runtime code at all — it just silences the compiler. JSON.parse(...) as Shape compiles, but the runtime decides what the value actually looks like, and the error detonates far from where it was planted. as has legitimate uses (inside type guards, in test code, refining DOM queries), but using it in place of narrowing means switching off the compile-time guarantee by hand.

Follow-up: “Why is typeof narrowing not enough?” — typeof answers "object" for every object, so it cannot tell union members apart; discriminated unions are the narrowing tool built for object unions. The foundations TS lesson on this site narrows SettledResult by status with the same move — this question just goes one level deeper. “Why must the discriminant be a literal type?” — if both members type the field as string, the compiler has nothing to tell them apart by. Expect assertNever too: extract those two default lines into function assertNever(x: never): never and reuse it in every switch.

TypeScript判别联合与穷尽检查示意Illustrative
1type Shape =
2 | { kind: "circle"; radius: number }
3 | { kind: "square"; side: number }
4 | { kind: "rect"; width: number; height: number };
5
6function area(s: Shape): number {
7 switch (s.kind) {
8 case "circle":
9 return Math.PI * s.radius ** 2; // 这个分支里 s 已收窄为 circle
10 case "square":
11 return s.side ** 2;
12 case "rect":
13 return s.width * s.height;
14 default: {
15 // 穷尽检查:三个成员都处理过,s 在这里只能是 never。
16 // 将来加了第四种 kind 却忘了写 case,这一行立刻编译报错。
17 const exhausted: never = s;
18 return exhausted;
19 }
20 }
21}
1type Shape =
2 | { kind: "circle"; radius: number }
3 | { kind: "square"; side: number }
4 | { kind: "rect"; width: number; height: number };
5
6function area(s: Shape): number {
7 switch (s.kind) {
8 case "circle":
9 return Math.PI * s.radius ** 2; // inside this branch s is narrowed to circle
10 case "square":
11 return s.side ** 2;
12 case "rect":
13 return s.width * s.height;
14 default: {
15 // Exhaustiveness check: all three members are handled, so s can only be never here.
16 // Add a fourth kind and forget its case, and this line fails to compile at once.
17 const exhausted: never = s;
18 return exhausted;
19 }
20 }
21}
TypeScriptas 是闭嘴,收窄是证明示意Illustrative
1// as 是「让编译器闭嘴」,不是「向编译器证明」
2const draft = JSON.parse(localStorage.getItem("draft") ?? "{}") as Shape;
3// 编译器信了。但运行时这里可能是任何东西,错误被推迟到别处爆发
4
5// 类型守卫是「证明」:每一步收窄都有真实的运行时检查兜着
6function isShape(x: unknown): x is Shape {
7 if (typeof x !== "object" || x === null) return false;
8 if (!("kind" in x)) return false;
9 return x.kind === "circle" || x.kind === "square" || x.kind === "rect";
10}
11
12const raw: unknown = JSON.parse(localStorage.getItem("draft") ?? "{}");
13if (isShape(raw)) {
14 area(raw); // ✓ 这一行的安全是运行时检查换来的,不是宣称出来的
15}
1// as tells the compiler to be quiet; it does not prove anything to the compiler
2const draft = JSON.parse(localStorage.getItem("draft") ?? "{}") as Shape;
3// The compiler believes it. At runtime this can be anything, and the error surfaces elsewhere
4
5// A type guard is a proof: every narrowing step is backed by a real runtime check
6function isShape(x: unknown): x is Shape {
7 if (typeof x !== "object" || x === null) return false;
8 if (!("kind" in x)) return false;
9 return x.kind === "circle" || x.kind === "square" || x.kind === "rect";
10}
11
12const raw: unknown = JSON.parse(localStorage.getItem("draft") ?? "{}");
13if (isShape(raw)) {
14 area(raw); // ✓ this line is safe because of the runtime check, not because we said so
15}