一个用联合类型加 switch 处理多种形状的函数,在我新增了一种类型后悄悄漏掉了它、TS 却一声不吭,直到线上才暴露:一次 TypeScript 穷尽检查缺失的深度复盘
那个 bug 是新功能上线后冒出来的:我们用 TypeScript 的联合类型描述了几种"图形",有个函数用 switch 根据图形类型计算面积。某次迭代,产品要加一种新图形"三角形",我在类型定义里加了它、在创建图形的地方也处理了它,自信地上线了。结果用户一用到三角形,面积计算就出错——返回了 0 或 undefined。我排查后才发现:那个算面积的 switch 函数里,我压根忘了加"三角形"这个 case,于是三角形走进了 default(或干脆没匹配上),没被正确处理。可让我最难受的是:我加了一种新类型,却漏改了一处该改的地方,而 TypeScript 从头到尾一声不吭、编译完全通过——它没有提醒我"你这个 switch 没处理三角形"。我一直以为 TS 的类型系统能帮我"改了类型,所有相关的地方都会报错提示我去改全",可这次它没有。直到我研究后才明白:TypeScript 默认不会对联合类型的 switch/if 做"穷尽性检查(exhaustiveness check)"——它不会自动帮你检查"这个联合类型的每一个成员,你是不是都处理了"。除非你主动地用一种特定的模式(配合 never 类型)去"要求它做穷尽检查,它才会在你漏掉分支时报错。我没有用这个模式,所以漏了三角形,它也不管。这篇就把这次"联合类型缺穷尽检查、漏处理新成员"的坑,从头到尾复盘一遍。
故障现场:switch 漏了新增的联合类型成员
问题代码,是一个用 switch 处理联合类型、却没做穷尽检查的函数:
// 联合类型: 几种图形
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number }; // ★ 后来新增的
// ✗ 出问题的函数: switch 处理, 但没做穷尽检查
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
// ✗ 忘了加 "triangle" 的 case! 而 TS 编译完全不报错!
default:
return 0; // ✗ triangle 走到了这里, 返回0 → 面积错!
// (如果没有default, triangle会走完switch返回undefined)
}
}
// 我做的:
// - 在 Shape 类型里加了 triangle(改了类型定义);
// - 在"创建图形"的地方处理了 triangle;
// - 但【漏改了 area 函数】, 没加 triangle 的 case;
// - 而 TypeScript 【编译通过、毫无提示】 → 我以为改全了 → 上线 → 三角形面积错。
// 为什么 TS 不报错:
// - TS 默认【不强制】你处理联合类型的每一个成员;
// - 一个 switch 漏了某个case, TS 不认为这是错误(也许你就是故意只处理部分);
// - → 它【不会自动做"穷尽性检查"】, 除非你用特定模式主动要求它做。
// 关键: TS默认不对联合类型做穷尽检查; 给联合类型加新成员后, 那些"按成员分支处理"的地方
// (switch/if), 漏改了TS也不报错——靠TS"改类型自动报错提示改全"的期待落空了。
第一次意识到 TS"没帮我兜住"时,我有点失落:"我用 TS,图的不就是'改了类型,该改的地方都会被标红提示我'吗?这次它怎么不灵了?"这个坑最隐蔽的地方在于:它击穿了我们对 TS 的一个核心期待——"类型安全能在我改动时,帮我找出所有需要同步修改的地方"。对很多改动(改字段名、改参数类型),TS 确实能做到(用到的地方会报错);但对"给联合类型加新成员"这种,TS 默认做不到——它不会自动提醒你"哪些 switch 还没处理新成员",于是漏改的地方就静默地留下了 bug。下面就来拆解,什么是穷尽检查、怎么让 TS 帮你做。
第一件事:搞懂穷尽性检查,以及 TS 默认为什么不做
我认真研究了 TS 的联合类型和穷尽检查,才彻底理解这个坑和正解。
穷尽性检查(exhaustiveness check) 与 TS 的默认行为
【核心: TS默认不强制处理联合类型的每个成员; 想让它在"漏处理某成员"时报错, 要主动用never做穷尽检查】
1. 联合类型 + 按成员分支处理:
- type Shape = A | B | C; 经常要 switch(shape.kind) 对每种成员分别处理;
- 这是"可辨识联合(discriminated union)"的典型用法(用一个公共字段kind区分)。
2. TS 默认不做穷尽检查:
- 一个 switch 只处理了部分成员、漏了某个, TS【默认不报错】;
- 因为TS不知道你"是想处理全部"还是"故意只处理部分"——它不替你假设;
- → 给联合类型【加新成员】后, 那些漏改的switch, TS不会提醒你 → 静默遗漏。
3. 怎么让TS帮你做穷尽检查——用never:
- 原理: 当switch处理完所有【已知】成员后, 在default里, 剩下的类型会被TS收窄为 never;
- never 表示"不可能存在的类型"; 你把这个值赋给一个 never 类型的变量;
- 如果你【漏了】某个成员, 那个成员的类型就【不是never】(还有它没处理), 赋给never会【编译报错】!
- → 于是: 漏处理某成员 → never断言失败 → 编译期就报错 → 强制你去补上。
4. 这个模式的价值:
- 它把"漏处理联合成员"这个错误, 从【运行时的静默bug】, 提前到了【编译期的明确报错】;
- → 尤其当你【加新成员】时, 所有用了穷尽检查的地方会【立刻编译报错】, 像一张清单告诉你"这些地方要改";
- → 这正是我们期待TS帮我们做的"改类型, 自动找出要同步改的地方"!
5. 推论: 联合类型的分支处理, 应该主动加穷尽检查;
- 别依赖TS"默认会提醒", 而要用never模式"显式地要求它检查"。
一句话: TS默认不对联合类型做穷尽检查(漏处理某成员不报错); 想让它帮你, 要在default里用never
做穷尽断言——漏成员就编译报错; 这把"漏分支"从运行时静默bug提前到编译期明确报错。
这套机制,是整个坑的根。联合类型+按成员分支处理:经常用 switch 对可辨识联合(用公共字段 kind 区分)的每种成员分别处理。TS 默认不做穷尽检查:一个 switch 漏了某成员 TS默认不报错(它不知道你是想处理全部还是故意只处理部分、不替你假设),于是给联合类型加新成员后那些漏改的 switch TS 不会提醒、静默遗漏。怎么让 TS 帮你做?用 never:处理完所有已知成员后,在 default 里剩下的类型会被 TS 收窄为 never(表示"不可能存在的类型"),把它赋给一个 never 变量;如果漏了某成员,那个成员的类型就不是 never、赋给 never 会编译报错,强制你补上。这个模式的价值:把"漏处理联合成员"从运行时静默 bug 提前到编译期明确报错;尤其加新成员时,所有用了穷尽检查的地方会立刻编译报错、像清单告诉你哪些要改——这正是我们期待 TS 帮我们做的"改类型自动找出要同步改的地方"。一句话:TS 默认不对联合类型做穷尽检查(漏处理某成员不报错);想让它帮你,要在 default 里用 never 做穷尽断言——漏成员就编译报错;这把"漏分支"从运行时静默 bug 提前到编译期明确报错。
第二件事:正解——用 never 做穷尽检查,让漏分支编译报错
搞懂了原理,正解就清晰了:在 switch 的 default(或 if 的最后)用 never 做穷尽断言,漏处理任何成员都会编译报错;配合 strict 的 tsconfig,把"漏分支"挡在编译期。
// ====== 正解一: 在 default 里用 never 做穷尽检查 ======
function assertNever(x: never): never {
throw new Error("未处理的类型: " + JSON.stringify(x));
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "triangle": // 处理了所有成员
return 0.5 * shape.base * shape.height;
default:
// ★ 走到这里时, shape 的类型应被收窄为 never(所有成员都处理了);
// 如果【漏了】某个成员, shape 就不是 never → 这行【编译报错】!
return assertNever(shape);
}
}
// → 现在如果以后再加一个新成员(如 "pentagon")却忘了加case,
// default里的 assertNever(shape) 会【立刻编译报错】(shape此时是pentagon类型, 不是never);
// → 编译就过不了, 强制你去补上pentagon的处理 → 漏不掉了!
// ====== 正解二: 返回值场景, 也可以用 never 变量断言 ======
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle": return "圆";
case "square": return "正方形";
case "triangle": return "三角形";
default: {
const _exhaustive: never = shape; // ★ 漏了成员这里就报错
return _exhaustive; // 实际不会执行到
}
}
}
// ====== 正解三: 配合严格的 tsconfig ======
// {
// "compilerOptions": {
// "strict": true, // 开启全套严格检查
// "noImplicitReturns": true, // 不是所有路径都return → 报错(也能帮发现漏分支)
// "noFallthroughCasesInSwitch": true // switch的case漏写break穿透 → 报错
// }
// }
// → 严格的tsconfig能帮你挡住更多类似的"漏处理"问题。
// ====== 关键认知 ======
// - TS的类型安全不是"全自动的", 很多保护(如穷尽检查)需要你【主动用特定模式去触发】;
// - never是TS里专门表示"不可能"的类型, 是实现穷尽检查的关键工具。
// 核心: 联合类型的分支处理, 主动用 never(assertNever/never变量断言)做穷尽检查, 让"漏处理某成员"
// 变成编译期报错; 配合strict的tsconfig; 别依赖TS默认会提醒——很多保护要你主动开启/触发。
修复的核心,是"用 never 主动要求 TS 做穷尽检查,让漏分支在编译期就报错"。正解一:在 default 里用 never 做穷尽检查——定义 assertNever(x: never),在 default 调用 assertNever(shape);处理完所有成员时 shape 被收窄为 never、合法;一旦漏了某成员(或以后加了新成员忘处理),shape 就不是 never、这行立刻编译报错,强制你补上。正解二:返回值场景用 const _exhaustive: never = shape 断言。正解三:配合严格 tsconfig(strict、noImplicitReturns、noFallthroughCasesInSwitch)。关键认知:TS 的类型安全不是全自动的,很多保护(穷尽检查)需要你主动用特定模式触发;never 是 TS 里专门表示"不可能"的类型、是穷尽检查的关键工具。归根结底:联合类型的分支处理主动用 never 做穷尽检查、让漏处理某成员变成编译期报错;配合 strict 的 tsconfig;别依赖 TS 默认会提醒——很多保护要你主动开启/触发。
第三件事:TypeScript 类型保护"需要主动开启"的其他地方
排查后我把 TS 里其他"需要主动配置/编码才能享受到"的类型保护也系统梳理了一遍。
TS 类型保护"需要主动开启/触发"的其他地方
# 1. 联合类型穷尽检查(本文): 默认不检查, 要用never主动做。
# 2. strict模式: 不开strict, 很多检查(strictNullChecks等)是关的。→ 开启strict。
# 3. strictNullChecks: 不开它, null/undefined能赋给任何类型(NPE隐患)。→ 必开。
# 4. noUncheckedIndexedAccess: 不开它, arr[i]/obj[key]被当成一定有值(实际可能undefined)。→ 建议开。
# 5. noImplicitAny: 不开它, 推断不出类型就悄悄变any(类型安全失效)。→ 必开。
# 6. as/any/非空断言!: 这些会主动【关闭】检查(同504篇), 用了就失去保护。→ 少用。
# 7. 第三方类型不准: .d.ts可能和库实际行为不符, 给你错误的"安全感"。→ 关键处验证。
# 8. 运行时数据没校验: 外部数据(接口/输入)的类型TS保证不了, 要运行时校验(同504篇)。
# 共同根源: TS的类型安全程度, 取决于你【怎么配置它、怎么用它】——它提供了一套强大的工具,
# 但很多保护默认是关的/需要主动触发的; 不主动开启/正确使用, 就享受不到全部的保护。
# 核心: 开启严格的tsconfig(strict全家桶); 联合类型用never做穷尽检查; 少用as/any/!;
# 外部数据运行时校验; 把"TS的保护"从"默认有限"提升到"主动开满"——类型安全要主动争取。
排查让我把 TS 类型保护的其他点也梳理清了。一、联合类型穷尽检查(本文)。二、strict 模式(不开很多检查是关的)。三、strictNullChecks(必开)。四、noUncheckedIndexedAccess。五、noImplicitAny(必开)。六、as/any/非空断言!(主动关闭检查)。七、第三方类型不准。八、运行时数据没校验。它们的共同根源是:TS 的类型安全程度,取决于你怎么配置它、怎么用它——它提供了强大的工具,但很多保护默认是关的/需要主动触发的;不主动开启/正确使用,就享受不到全部保护。核心是:开启严格的 tsconfig(strict 全家桶);联合类型用 never 做穷尽检查;少用 as/any/!;外部数据运行时校验;把"TS 的保护"从"默认有限"提升到"主动开满"——类型安全要主动争取。下面这张图,是这次穷尽检查缺失坑的成因与解法:
第四件事:可辨识联合 + 穷尽检查实践速查表
这次踩坑后,我把"用好可辨识联合"的实践整理成一张表。
| 要点 | 怎么做 | 作用 |
|---|---|---|
| 用公共字段区分 | 每个成员有 kind: "xxx" 字面量 | TS能据它收窄类型 |
| switch按kind分支 | case "circle"... | 各成员分别处理 |
| 穷尽检查 | default里 assertNever(shape) | 漏成员就编译报错 |
| 新增成员 | 加到联合类型 | 所有穷尽检查处立刻报错提示改 |
| never的角色 | 表示"不可能的类型" | 穷尽检查的关键 |
这张表把可辨识联合的用法钉清了。核心是:"可辨识联合(discriminated union)+ switch 分支 + never 穷尽检查"是 TypeScript 里处理"一个值有几种不同形态"的黄金组合——用公共字段(kind)区分、switch 分别处理、never 兜底保证不漏;这套组合让"类型的种类"和"对每种类型的处理"被牢牢绑定:加一种类型,所有该处理它的地方都会被编译器揪出来。它给我的最大启发是:好的类型设计,能把"容易遗漏的人工同步"变成"编译器强制的检查"——"加了一种新情况,要记得在所有相关的地方都处理它"这种靠人记忆、极易遗漏的事,通过可辨识联合+穷尽检查,变成了编译器替你把关(漏了就编译不过);这就是"用类型系统把约束编码进去"的威力——把"但愿我没忘"变成"编译器保证我没忘"。这让我对类型系统有了更主动的认识:类型系统不只是"标注一下类型、防低级错误"的工具,更是一种"把业务约束和不变量编码进去、让编译器帮你强制维护"的手段——"这几种情况必须都处理""这个值不可能为空""这两个字段必须同时存在"——把这些约束用类型表达出来,编译器就成了你不知疲倦、绝不遗漏的检查员;"让非法状态无法表示、让遗漏无法编译通过",是用好类型系统的高阶境界。掌握可辨识联合+穷尽检查、用类型系统把约束编码进去让编译器强制维护——是这个坑带给我的认知。
第五件事:这个坑暴露的"对工具的能力要有准确认知"
这次让我反思:我对 TS 能力的认知有偏差。我把"我以为 TS 能做的"和"它实际默认做的"对比成表。
| 我以为TS会 | 实际默认 | 真相 |
|---|---|---|
| 改类型自动揪出所有要改的地方 | 部分能(用到处报错), 联合穷尽不能 | 要主动用never才行 |
| 联合类型漏处理会报错 | 不报错 | 默认不做穷尽检查 |
| 开箱即用全套保护 | 很多检查默认关 | 要开strict |
| 外部数据也类型安全 | 保证不了 | 要运行时校验 |
| 用了TS就高枕无忧 | 取决于怎么配怎么用 | 安全要主动争取 |
这张表道出了一个我认知上的偏差。核心是:我对 TS 的能力,有一种"过高且笼统"的期待——以为"用了 TS,它就会全自动地、无微不至地保护我";可实际上 TS 的保护是"有具体边界、且很多要主动开启/触发"的:有些事它默认就做(类型不匹配报错)、有些要你配置(strict)、有些要你用特定模式(never 穷尽检查)、有些它根本做不到(运行时数据)。它给我的深刻启发是:用任何工具,都要对它的能力有"准确而具体"的认知,而不是"笼统的迷信或贬低"——既不要"过高估计"(以为它无所不能、从而依赖它没有的能力、放松本该自己做的防范),也不要"过低估计"(以为它没用、从而不去用它强大的能力);"清楚它具体能做什么、不能做什么、什么要主动开启",才能既充分利用它的能力、又对它的盲区主动补位。这给了我一种使用工具的成熟态度:花时间去真正搞清楚一个常用工具的"能力边界和正确用法"(读它的文档、了解它的配置项和最佳实践),是一项回报极高的投资——很多人用一个工具很多年,却只用了它能力的一小部分、还对它的边界一知半解,于是既没发挥它的威力、又在它的盲区反复踩坑;"把工具吃透",让它的每一分能力都为你所用、每一处盲区都被你预知——这是从"会用工具"到"善用工具"的关键。对工具的能力有准确具体的认知、把常用工具吃透——是这个 TS 坑带给我的认知。
第六件事:用联合类型分支处理时,我现在的习惯
现在每当我用 switch/if 处理一个联合类型,我都会按这张图先想一想:
这张图的精髓,是"要全覆盖联合成员就加 never 穷尽检查"。需要处理所有成员就在 default 加 never 穷尽检查(assertNever);以后加新成员时所有穷尽检查处会编译报错、像清单指引你改全;配合 strict 的 tsconfig。这套习惯,让我从"switch 随手处理几个 case"变成了"处理联合类型就加穷尽检查"——核心始终是:联合类型要全覆盖就用 never 做穷尽检查,让漏分支和新增成员忘处理都编译报错。
我立下的几条规矩
这场"联合类型缺穷尽检查、漏处理新成员"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- TS 默认不对联合类型做穷尽检查。漏处理某成员,它不报错。
- 用 never 做穷尽检查。default 里 assertNever(x),漏成员就编译报错。
- 加新成员时,穷尽检查处会编译报错。像一张清单指引你把相关处改全。
- TS 很多保护需要主动开启/触发。strict、never 穷尽检查,别依赖默认。
- 用可辨识联合(公共 kind 字段)。让 TS 能据它收窄类型。
- 用类型系统把约束编码进去。把"但愿没忘"变成"编译器保证没忘"。
- 对工具的能力有准确具体的认知。清楚它能做、不能做、什么要主动开。
写在最后
回头看,这场由"switch 漏处理一个联合成员"引发的、TS 没拦住的事故,真正教给我的,远不止"联合类型要用 never 做穷尽检查"这一个技巧。它让我对"工具的'保护',不是被动地'领取',而是要主动地'争取'",有了一次刻骨的体会。我栽跟头,根源在于我对 TS 的保护抱着一种"被动领取"的心态:我以为"我用了 TS,它自然就会把所有能帮我挡的都挡了",于是我什么都没多做,就坐等它的保护。可这次它没挡住——因为"联合类型穷尽检查"这道保护,TS 并不会主动给,它需要我主动地、用特定的方式(never 模式)去"请求"它做。我把"工具提供了某种能力"和"工具会主动为我使用这种能力"混为一谈了——前者是"它有这个本事",后者是"它会替我使出来",这中间隔着"我要不要主动去用"。这让我领悟到一个关于"用好工具/机制"的深刻认知:很多强大的保护和能力,工具提供了、但不会主动替你启用——它们是"需要你主动开启、主动配置、主动用对模式去触发"的潜能,而非"开箱即得"的默认;"用了这个工具"≠"用足了这个工具的保护";真正的安全和效率,来自主动地把工具的能力"用满、用对",而不是被动地享受它的默认行为。这给了我一种主动的工程姿态:对待手里的工具(类型系统、Lint、测试框架、监控、IDE),要有一种"主动榨取它全部价值"的态度——去探索"它还能为我做什么我没让它做的事"、主动开启更严格的检查、主动用上更强的模式、主动配置更全的规则;"把工具的保护从'默认的下限'主动提升到'可达的上限'",让它为你挡住尽可能多的错误——这种主动性,是把工具的潜能转化为实际生产力的关键。认清工具的保护要主动争取而非被动领取、主动把工具的能力用满用对——这,是我用一次穷尽检查缺失的事故,换来的、关于 TypeScript、也关于如何真正用好一切工具的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写处理联合类型的 switch 时,顺手在 default 加上一行 assertNever,那我对着那个漏算的三角形排查的这段时间,就值了。
—— 别看了 · 2026