这是一个"加了功能,旧功能却悄悄漏了"的事故,而且漏得无声无息,直到用户反馈才发现。我们有一个消息处理器,负责处理几种类型的消息(用一个联合类型定义),用一个 switch 分门别类地处理它们。某次需求,产品要新增一种消息类型,我很自然地在那个联合类型的定义里,加上了新类型,然后改了相关逻辑,编译通过、测试我新加的功能也正常,就上线了。可上线后,陆续有用户反馈:某一类消息"没反应"、像石沉大海。排查半天才发现:我新增了消息类型、却忘了在那个 switch 里,加上处理这个新类型的 case;于是这个新类型的消息,在 switch 里没有任何分支接住它,悄悄地溜走了——而 TypeScript 编译器,对此竟然一声没吭、毫无报错。
这件事让我对 TypeScript 的类型系统,产生了一个新的期待和反思:我一直觉得,TS 既然有强大的类型系统、知道我这个联合类型有哪几个成员,那当我处理它、却漏掉了某个成员时,它理应能提醒我"喂,你还有个成员没处理呢!"——可它默认并不会。它默默地允许我"只处理联合类型的一部分成员",对那些被我遗漏的成员,睁一只眼闭一只眼。而这,恰恰是大量"新增了一个类型,却忘了在所有该处理它的地方都处理它"这类隐蔽 bug 的温床。好在,TypeScript 提供了一种机制,能让"漏处理一个成员"从"运行时才暴露的隐蔽 bug",变成"编译期就报错的显式错误"——那就是基于 never 类型的穷尽性检查(exhaustiveness check)。这篇文章,就从这次"新增类型漏处理、消息悄悄丢失"的事故讲起,聊聊怎么用好 TypeScript 的联合类型与穷尽性检查。
故障现场:一个没人接住的新类型
先把那段"漏了"的代码还原一下:
// 消息的联合类型, 一开始有三种
type Message =
| { type: "text"; content: string }
| { type: "image"; url: string }
| { type: "video"; url: string };
function handle(msg: Message) {
switch (msg.type) {
case "text": return renderText(msg.content);
case "image": return renderImage(msg.url);
case "video": return renderVideo(msg.url);
}
}
// 后来, 产品要新增一种"文件"消息, 我在联合类型里加上了它:
type Message =
| { type: "text"; content: string }
| { type: "image"; url: string }
| { type: "video"; url: string }
| { type: "file"; url: string; name: string }; // 新增! 但...
// 我忘了在上面那个 handle 的 switch 里加 case "file" 了!
// 而 TypeScript 编译: 一声不吭, 通过! → "file" 消息没人处理, 悄悄溜走
看出问题了吗?我给 Message 这个联合类型加了第四个成员 file,但那个 handle 函数里的 switch,还停留在只处理 text/image/video 三个 case 的状态——我忘了同步给它加上 case "file"。结果就是:当一条 file 类型的消息进来,这个 switch 的三个 case 都不匹配,它就直接"穿过" switch、什么也没做地走了——消息被悄悄丢弃,没有任何报错、没有任何异常。而最让我意外的是:TypeScript 明明知道 Message 现在有四个成员、也知道这个 switch 只处理了三个,可它编译时竟然完全没有报错、没有任何提示。
这就是这个坑最阴险的地方:它是一个"沉默的遗漏"。编译器不报错(它默认允许你只处理联合类型的一部分)、运行时不抛异常(没匹配的 case 就是什么都不做),于是这个 bug 就以最隐蔽的方式潜伏了下来——新功能(file 消息能发了)看起来一切正常,而旧逻辑(处理消息)却悄悄地对这个新类型"失明"了。它之所以难发现,正是因为它太安静了:没有红色的报错、没有崩溃的堆栈,只有那些 file 类型的消息,在用户那头无声无息地"消失"。这种"新增一个联合类型成员,却忘了在某个处理它的地方同步处理"的遗漏,在代码库里那些 switch/if 分派联合类型的地方,简直防不胜防。
第一件事:理解 TS 默认"不强制穷尽处理"联合类型
要避开这个坑,先得认清一个事实:TypeScript 默认情况下,并不会强制你"处理一个联合类型的每一个成员";你只处理其中一部分、遗漏了另一部分,它默认是允许的、不报错的。这其实是一个合理的默认行为(很多时候你确实只想处理联合类型里的某几个成员),但它的代价,就是无法帮你自动发现"我本应处理全部、却漏了一个"的情况。
type Status = "pending" | "success" | "failed";
function describe(s: Status): string {
if (s === "pending") return "处理中";
if (s === "success") return "成功";
// 漏了 "failed"! 但 TS 默认不报错
// 当 s 是 "failed" 时, 这个函数会走到底、返回 undefined(而声明返回 string!)
}
// 编译通过, 但 describe("failed") 返回了 undefined —— 一个潜在的 bug
关键认知是:TypeScript 的类型系统,虽然"知道"一个联合类型有哪些成员,但它默认不会"强制"你把每个成员都处理掉——遗漏成员,在它看来是合法的。所以,你不能指望"反正 TS 会帮我检查有没有漏处理"——它默认不会。这就意味着,在那些"应该处理联合类型的所有成员"的地方(比如一个消息分发器、一个状态机的状态处理),如果你不做额外的处理,那么"漏掉某个成员"这种错误,TS 是不会替你拦住的,它会一路放行到运行时。而最危险的时刻,正是"给联合类型新增成员"的时候——因为新增成员后,所有"原本处理了全部成员"的地方,瞬间就都变成了"漏了新成员"的状态,而 TS 对这种"突然出现的遗漏"默不作声。我那次,就是栽在这个"新增成员、却没有任何机制提醒我去同步所有处理点"的盲区里。那么,有没有办法让 TS"强制"我处理每一个成员呢?有——这就是下一节的穷尽性检查。
第二件事:正解——用 never 做穷尽性检查
解药是 TypeScript 一个非常优雅的技巧——用 never 类型,在 switch 的 default 分支(或 if-else 的最后)做"穷尽性检查"。它的原理巧妙:当你正确处理了所有成员后,走到 default 时,那个变量的类型会被 TS 收窄成 never(因为所有可能性都被前面的 case 排除了);而你把它赋给一个 never 类型的变量,只要还有成员没被处理,这个赋值就会编译报错。
function handle(msg: Message) {
switch (msg.type) {
case "text": return renderText(msg.content);
case "image": return renderImage(msg.url);
case "video": return renderVideo(msg.url);
// case "file" 漏了!
default:
// 穷尽性检查: 把 msg 赋给 never 类型变量
const _exhaustive: never = msg;
// ↑ 因为漏了 "file", 走到 default 时 msg 的类型是 {type:"file",...} 而非 never
// 把它赋给 never 会编译报错: "Type ... is not assignable to type never"
throw new Error(`未处理的消息类型: ${(msg as any).type}`);
}
}
// 现在: 漏处理 "file" → 编译期就红色报错! 你被迫去加上 case "file"
// 把它封成一个工具函数, 更清晰:
function assertNever(x: never): never {
throw new Error(`未处理的类型: ${JSON.stringify(x)}`);
}
// default: return assertNever(msg); // 漏 case 就编译报错
这个技巧的精妙之处,在于它把 TS 的类型收窄机制反过来"为我所用":当你处理完所有 case,走到 default 时,TS 会推断出"此处 msg 已经不可能是任何已知类型了",于是把它的类型收窄成 never(代表"不可能存在的值");而把一个 never 赋值是合法的。可一旦你漏处理了某个成员(比如 file),走到 default 时,msg 就还可能是 file 类型,它的类型就不是 never 而是 {type:"file",...}——这时把它赋给 never 类型的变量,就会触发编译错误!这就巧妙地实现了"漏处理一个成员 = 编译期报错"。它把那个原本"沉默潜伏到运行时"的遗漏 bug,变成了一个"你不处理就编译不过"的、无法忽视的显式错误。更妙的是,这道防线在"将来"也持续有效:以后任何人再给这个联合类型新增成员,所有用了穷尽性检查的处理点,都会立刻编译报错,逼着他去把新成员处理掉——这等于给"新增类型"这个高危操作,装上了一个自动的、强制的"别忘了处理我"的提醒。我把"有没有穷尽性检查"两条路径的命运画成图:
这张图的对比一目了然:没有穷尽性检查,新增成员就是一颗"编译不报错、运行才暴露"的隐形炸弹;有了穷尽性检查,新增成员会立刻在所有该处理它的地方亮起红灯,逼你处理。这正是穷尽性检查最大的价值——它把"靠人记得去同步所有处理点"这件极易出错的事,变成了"编译器强制保证"的事。对那些"应该处理联合类型全部成员"的关键分派逻辑,加上 never 穷尽性检查,几乎是零成本、却能根治一整类"漏处理"bug 的最佳实践。
第三件事:配合"可辨识联合",让收窄更顺畅
穷尽性检查能发挥威力,有一个前提——你的联合类型最好是"可辨识联合"(Discriminated Union),也就是每个成员都有一个共同的、值唯一的"标签字段"(比如上面的 type 字段),TS 能靠它来精确地区分和收窄类型。这其实是设计联合类型时一个非常值得遵循的好模式。
// 好的设计: 可辨识联合 —— 每个成员都有一个共同的、值唯一的标签字段 type
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
// ↑ kind 就是"判别标签", 值各不相同(circle/rect/triangle)
function area(s: Shape): number {
switch (s.kind) { // 靠 kind 来判别和收窄
case "circle": return Math.PI * s.radius ** 2; // 这里 s 被收窄为 circle, 能访问 radius
case "rect": return s.width * s.height;
case "triangle": return 0.5 * s.base * s.height;
default: return assertNever(s); // 穷尽性检查
}
}
// 判别标签(kind)+ 穷尽性检查(assertNever)= 既能精确收窄、又能强制处理全部成员
可辨识联合是 TypeScript 里处理"一个值有好几种不同形态"这类场景的黄金模式,它的好处是双重的:一是"能精确收窄"——靠那个共同的判别标签(kind/type),TS 能在每个 case 里把类型精确地收窄到对应的成员,让你安全地访问那个成员特有的字段(比如在 circle 分支里访问 radius,在 rect 分支里访问 width);二是"能配合穷尽性检查"——正因为有了清晰的判别标签,switch 才能干净利落地分派,never 穷尽性检查才能顺畅地工作。所以,当你要设计一个"有多种形态"的类型时(消息类型、形状、事件、状态、API 的不同响应……),优先用"可辨识联合"——给每个成员加一个共同的、值唯一的判别标签字段,再配上穷尽性检查的 switch。这套"可辨识联合 + 穷尽性检查"的组合,是 TypeScript 表达和处理"多态数据"最优雅、也最安全的方式,它让"新增一种形态、却忘了在某处处理它"这种错误,几乎无所遁形。
第四件事:还有几种"让遗漏在编译期暴露"的办法
除了 never 穷尽性检查,还有几种思路也能帮你把"漏处理"这类问题提前到编译期暴露,各有适用场景。
// 办法1: 用 Record 映射代替 switch —— 类型系统强制你覆盖每个 key
type Message = { type: "text" } | { type: "image" } | { type: "video" } | { type: "file" };
// Record<联合的所有 type, 处理函数>: 漏掉任何一个 type, 编译就报错!
const handlers: Record void> = {
text: (m) => renderText(m),
image: (m) => renderImage(m),
video: (m) => renderVideo(m),
file: (m) => renderFile(m), // 漏了它? Record 编译就会报错: 缺少属性 "file"
};
function handle(msg: Message) { handlers[msg.type](msg); }
// 优点: 比 switch 更声明式, 且天然强制完整(漏 key 就报错)
// 办法2: 函数声明明确的返回类型 + 开启 noImplicitReturns
function describe(s: Status): string { // 明确声明返回 string
switch (s) {
case "pending": return "处理中";
case "success": return "成功";
case "failed": return "失败";
}
// 开启 noImplicitReturns 后, 若有路径没 return, 编译会报错
}
这两种办法的思路,和穷尽性检查殊途同归,都是"让类型系统/编译器,替你强制覆盖所有情况":办法1(Record 映射)特别优雅——用 Record<Message["type"], 处理函数> 定义一个"类型到处理函数"的映射对象,TypeScript 会强制你为联合里的每一个 type 都提供一个处理函数,漏掉任何一个,定义这个对象时就直接编译报错。它比 switch 更声明式、更简洁,而且天然就是"完整"的。办法2是配合 tsconfig 的 noImplicitReturns 等严格选项——它能在"函数有的路径没有返回值"时报错,间接帮你发现"漏了某个 case 导致没 return"的情况。这些办法的共同精髓是:把"我得记得处理所有情况"这件靠自觉的事,转化成"不处理所有情况就编译不过"这件靠强制的事——用类型系统的强制力,替代人脑不可靠的记性。哪种更合适,取决于你的具体场景:逻辑分支复杂用 switch + assertNever,简单的"类型到处理"映射用 Record 更清爽。把这几种"防遗漏"手段整理成一张表:
| 手段 | 怎么防遗漏 | 适用 |
|---|---|---|
| never 穷尽性检查 | default 里 assertNever, 漏 case 编译报错 | switch 分派复杂逻辑 |
| Record 映射 | Record<联合, 处理> 强制覆盖每个 key | 类型到处理的简单映射 |
| 明确返回类型 + noImplicitReturns | 有路径没 return 就报错 | 有返回值的分派函数 |
| ESLint 规则 | 如 switch-exhaustiveness-check 直接检查 | 团队统一强制 |
第五件事:让"修改"的影响,在编译期就显形
这次事故,本质上暴露的是一个比"漏一个 case"更普遍的问题——当你修改一处(给联合类型加成员),它的影响会波及很多处(所有处理这个类型的地方),而你很容易遗漏其中某些处。而穷尽性检查这类技巧的真正价值,在于它能让"这次修改影响了哪些地方、哪些地方需要同步改"这件事,在编译期就清清楚楚地暴露出来。我把这种"让修改影响显形"的思路价值列成一张表:
| 没有这类机制 | 有了(穷尽检查/强类型) |
|---|---|
| 改一处, 靠人记得去同步改其它处 | 改一处, 编译器自动标出所有要改的地方 |
| 漏改的地方默默潜伏到运行时 | 漏改的地方编译期就红色报错 |
| 新人/改动者不知道还有哪些处理点 | 编译错误清单就是"待办列表" |
| 靠测试覆盖才可能发现 | 编译期就拦住, 不依赖测试 |
这张表传达的,是静态类型语言一个被低估的巨大价值:它能让"一处修改的连锁影响",在编译期就被精确地暴露出来,把"我改了 A,还有哪些地方需要跟着改"这个极易遗漏的问题,变成一份编译器自动生成的、red 标出来的"待办清单"。这正是 TypeScript(以及一切强类型系统)相比弱类型语言最大的优势之一——你重构、你修改、你扩展时,它能像一张精确的"影响地图",告诉你这次改动牵动了哪些地方、哪些地方还没改完。而穷尽性检查,就是把这份"影响地图"的能力,从普通的类型检查,延伸到了"联合类型的成员是否被完整处理"这个更细的维度。善用它,你就把 TypeScript 从一个"只检查我写的类型对不对"的工具,升级成了一个"我改动后主动提醒我哪些地方需要同步跟进"的得力助手——而这,正是类型系统在"安全地修改和演进代码"这件事上,能给你的最大帮助。
一张"处理联合类型怎么防遗漏"的决策图
把这次踩坑沉淀成一张图。每当你要处理一个"有多种形态"的联合类型时,照着它走:
这张图把"安全处理联合类型"的最佳实践串了起来:用可辨识联合设计、用 switch+assertNever 或 Record 映射分派、让漏处理在编译期报错、让新增成员自动提醒所有处理点。把这套做法变成习惯,"新增类型漏处理、悄悄丢数据"这种沉默的坑,就再也咬不到你了。
我立下的几条联合类型规矩
这次"新增消息类型漏处理"的事故后,团队的 TS 规范里加了这么几条:
- 多形态数据用可辨识联合:有多种形态的值,设计成可辨识联合(每成员加唯一判别标签 type/kind),便于收窄和穷尽检查。
- 关键分派必加穷尽性检查:处理联合类型全部成员的 switch,default 里用 assertNever 做穷尽检查,漏成员就编译报错。
- 简单映射用 Record:类型到处理函数的简单映射,用 Record<联合, 处理>,天然强制覆盖每个成员。
- 开启严格编译选项:tsconfig 打开 noImplicitReturns 等,配合 ESLint 的 switch-exhaustiveness-check 规则。
- 新增联合成员要走查处理点:给联合类型加成员时,主动想/让编译器找出所有处理它的地方,逐一同步。
- 警惕沉默的遗漏:对"编译不报错、运行不报错、却悄悄漏了"的逻辑保持警觉,主动用类型机制把它显形。
- 用类型系统管理修改影响:重构/扩展时,善用类型检查这张"影响地图",让编译错误清单成为待办列表。
这几条里,第二条是直接根治这次事故的。而第七条背后的理念,是我这次最大的收获:把类型系统,从一个"被动地检查我写得对不对"的工具,主动地用成一个"在我修改时,提醒我哪些地方需要同步跟进"的助手。我那次的坑,根源不在于我不会写类型,而在于我没有主动地去利用类型系统"暴露修改影响"的能力——我加了个联合成员,却没有任何机制告诉我"还有哪些地方需要为这个新成员做改动"。而穷尽性检查,恰恰就是这样一个机制:它让"新增成员"这个修改的影响,自动地、强制地在所有相关处理点上显形。主动地为你的关键逻辑装上这种"修改影响自动显形"的机制,你就能在快速迭代、频繁修改的过程中,大大降低"改了一处、漏了另一处"这类遗漏的风险——这在一个需要长期演进、不断加功能的项目里,价值千金。
写在最后:好的类型,是会"主动报警"的安全网
这次"沉默的遗漏"事故,让我对 TypeScript 类型系统的价值,有了一层更深的体会。在那之前,我对类型系统的理解,还停留在比较被动的层面:它能帮我检查"我写的代码,类型用得对不对"——这当然很有用,但还只是"防止我犯当下的错"。而这次,穷尽性检查让我看到了类型系统更主动、也更强大的一面:一个设计得好的类型系统,不只是被动地校验你"现在写的对不对",更能主动地在你"未来修改时",像一张警觉的安全网,把那些"你改了 A、却忘了同步改 B"的遗漏,自动地拦截下来、报警出来。它守护的,不只是代码当下的正确,更是代码在漫长演进中的"持续正确"。
想通这一点,我对"如何用好类型系统"有了新的追求:不要满足于"让代码能通过类型检查"这个最低要求,而要主动地去设计类型、运用类型技巧(像可辨识联合、穷尽性检查这样),让类型系统成为你重构和演进代码时,一张能主动报警的、可靠的安全网。这是一种从"被动接受类型检查"到"主动设计类型来保护未来的自己"的转变。因为软件最大的成本和风险,往往不在"第一次写出来",而在"后续无数次的修改和扩展"——而恰恰是在这无数次修改里,"改了一处、漏了相关的另一处"是最高发、也最隐蔽的一类错误。一个会"主动报警"的类型设计,就是在为这些未来的修改,提前埋下一个个"你漏了这里"的自动提醒——它让你(以及未来接手的人)在每一次改动时,都有一个不知疲倦、绝不遗漏的编译器,帮你盯着所有需要同步的地方。
所以,如果你也在用 TypeScript(或任何强类型语言),我想把这次踩坑最想说的话送给你:请把类型系统,当成一个你可以主动"编程"的安全网来用,而不只是一个被动的语法检查器。用可辨识联合让数据形态清晰、用穷尽性检查让"漏处理"无所遁形、用强类型让"修改的连锁影响"在编译期显形……主动地设计你的类型,让它在未来的每一次修改中,都能替你和你的队友,把那些容易遗漏的地方,大声地报出来。那一条条在用户那头无声消失的消息,最终教给我的,正是这份"主动用类型系统守护代码长期正确"的智慧——它让我明白,类型系统真正的威力,不在于它能挑出我今天的一个笔误,而在于它能在我明天、后天、乃至很久以后的每一次修改里,都默默地、忠实地,替我守住那些我可能会遗忘的角落。愿你我都能把这张安全网,织得更密、用得更主动,让代码在一次次的演进中,始终稳稳地走在正确的路上。
—— 别看了 · 2026