本节目标:理解判别联合的构成要件(联合成员 + 公共判别字段 + 字面量类型),学会把「一堆可选字段」重构为「若干互斥形态」,掌握用
never做穷尽性检查的写法,并能判断什么场景该用判别联合、什么场景不该用。
7.2 判别联合(Discriminated Unions)
上一节我们把「一组固定取值」表达成了字面量联合。但业务里更复杂的情形是:一个对象整体有若干种互斥的形态,每种形态携带的字段还不一样。加载中的请求没有数据,成功的请求有数据,失败的请求有错误信息——它们都是「请求状态」,但结构不同。
判别联合(discriminated union,也叫 tagged union / algebraic data type)就是 TypeScript 处理这类建模的标准手法。它由三部分组成:
- 若干对象类型组成的联合;
- 每个成员都有一个同名的公共字段,称为判别字段(discriminant);
- 这个字段的类型是字面量类型,且各成员取值互不相同。
三者齐备后,编译器就能顺着判别字段自动收窄到具体成员。下面从痛点讲起。
先看一个糟糕的建模
设想我们要表示一次异步请求的状态。很多项目会这样写:
interface RequestState<T> {
loading: boolean;
data?: T;
error?: string;
}
这个接口「看起来能表达所有情况」,但它是不设防的。它允许下面这些在逻辑上互相矛盾的对象全部通过编译:
const a: RequestState<User> = { loading: true, data: someUser }; // 加载中却有数据?
const b: RequestState<User> = { loading: false }; // 加载完了但既无数据也无错误?
const c: RequestState<User> = { loading: false, data: u, error: "boom" }; // 同时成功与失败?
问题出在哪?loading、data、error 三个字段是彼此独立的,类型系统并不知道它们之间该有约束。于是使用者每次拿到这个对象,都得靠自觉去判断:
function render(state: RequestState<User>) {
if (state.loading) {
return "加载中...";
}
if (state.error) {
return `出错了:${state.error}`;
}
if (state.data) {
return `你好,${state.data.name}`;
}
return "?"; // 这行永远不该走到,但类型系统逼你写
}
注意最后那个 return "?"——它是类型系统逼你写出来的死代码。因为你无法向编译器证明「前面三个分支已经覆盖了全部情况」。而一旦有人新增了 cancelled 状态,这里不会有任何编译错误提醒你去补分支。
用判别联合重构
把上面这个接口拆成三个互斥的形态,各自带一个 status 判别字段:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
现在再看同一段渲染逻辑:
function render(state: RequestState<User>): string {
switch (state.status) {
case "idle":
return "尚未开始";
case "loading":
return "加载中...";
case "success":
return `你好,${state.data.name}`; // 这里 state 已收窄,data 必定存在
case "error":
return `出错了:${state.error}`; // 这里 error 必定存在
}
}
变化是根本性的:
case "success"分支里,state.data的类型是User,不是User | undefined,不需要任何可选链或断言;- 在
case "loading"分支里尝试访问state.data会直接编译报错,因为该形态根本没有这个字段; - 不可能再构造出「加载中却带数据」这种矛盾对象,编译器在赋值处就会拦下;
- 函数不再需要那句兜底的
return "?"——不过前提是打开了穷尽性检查,见下文。
判别字段的选取原则
判别字段(也叫 tag)的选择有两条硬性要求,和一条软性建议。
硬性要求一:所有成员都必须有该字段,且名字相同。
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
如果某个成员漏了 kind,判别联合就退化成普通联合,收窄不会生效。
硬性要求二:各成员的取值必须是字面量类型且互不重复。
// 错误示范:kind 写成 string,收窄失效
type Bad =
| { kind: string; radius: number }
| { kind: string; side: number };
两个成员都是 string,编译器无法从值区分它们。必须是 "circle" | "square" 这样的字面量。
软性建议:字段名用 kind / type / tag / status 等语义明确的词。 本书统一使用 kind 表示「这是什么形态」,用 status 表示「处于什么状态」,用 type 表示「这是哪类事件/消息」。团队内保持一致比选哪个词更重要。
穷尽性检查:让新增分支变成编译错误
判别联合最有价值的收益是穷尽性检查(exhaustiveness check):当联合新增一个成员时,编译器能指出所有没处理它的地方。实现方式是借 never 类型做一次兜底。
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default: {
const exhaustive: never = shape;
throw new Error(`未处理形状: ${JSON.stringify(exhaustive)}`);
}
}
}
never 是「不可能存在的值」的类型(在 3.3 any·unknown·never·void 与类型断言
中介绍过)。如果所有成员都被 case 覆盖,走到 default 时 shape 的类型已经被收窄成 never,const exhaustive: never = shape 合法。反之,一旦有人新增了 kind: "triangle" 却没加分支,default 里的 shape 就是 { kind: "triangle"; ... },赋值给 never 会立刻报错:
Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.
这就是「漏处理分支」从一个运行期 bug 变成了编译期错误。
也可以把这段兜底逻辑抽成一个工具函数,供全项目复用:
function assertNever(value: never, message = "unexpected value"): never {
throw new Error(`${message}: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
return assertNever(shape, "未处理的形状");
}
}
在 if/else 链里同样适用,只是需要显式收尾:
function describe(value: string | number | boolean): string {
if (typeof value === "string") return `字符串: ${value}`;
if (typeof value === "number") return `数字: ${value}`;
if (typeof value === "boolean") return `布尔: ${value}`;
return assertNever(value); // 三个分支都覆盖后,value 为 never
}
判别联合与「可选字段」的取舍
判别联合不是唯一解。有些场景用可选字段反而更简洁,判断依据是「字段之间是否存在互斥约束」。
| 场景特征 | 推荐写法 | 原因 |
|---|---|---|
| 多个字段互斥,同一时刻只有一组有效 | 判别联合 | 类型层面禁止矛盾组合 |
| 字段彼此独立,可任意组合 | 普通 interface + 可选字段 | 判别联合会把成员数组合爆炸 |
| 需要穷尽性检查(新增分支要报错) | 判别联合 | 只有联合能配 never 兜底 |
| 只是「可能有也可能没有」 | field?: T 或 T | undefined | 判别联合属于过度设计 |
| 状态机式流转(idle→loading→success/error) | 判别联合 | 每个状态的载荷不同 |
一个反例:如果对象有 5 个彼此独立的开关,用判别联合会写出 2⁵ = 32 个成员,完全不可维护。此时应该老老实实用 5 个布尔字段。
在真实项目中的典型用法
判别联合最常见的三个落点是状态、事件/动作、结果。
第一类是请求状态,前面已经演示。第二类是 Redux 风格的动作:
type Action =
| { type: "user/login"; payload: { email: string; password: string } }
| { type: "user/logout" }
| { type: "cart/add"; payload: { sku: string; qty: number } }
| { type: "cart/clear" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "user/login":
return { ...state, user: login(action.payload) };
case "user/logout":
return { ...state, user: null };
case "cart/add":
return { ...state, cart: addItem(state.cart, action.payload) };
case "cart/clear":
return { ...state, cart: [] };
}
}
这里 action.type 就是判别字段,case 分支里 action.payload 的类型各不相同,全靠 type 收窄。
第三类是「成功/失败」结果,配合错误处理使用:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number> {
const n = Number(input);
if (!Number.isInteger(n) || n < 0) {
return { ok: false, error: new Error(`非法年龄: ${input}`) };
}
return { ok: true, value: n };
}
const result = parseAge("42");
if (result.ok) {
console.log(result.value); // number
} else {
console.error(result.error.message); // Error
}
ok: true / ok: false 这对布尔字面量就是判别字段,用法与字符串判别字段完全一致。这套模式在 14.1 错误类型与 Result 模式
会专门展开,如果你想提前了解,可以看本站的 TypeScript 错误处理与 Result 模式
。
常见坑与错误信息
| 现象 | 原因 | 处理方式 |
|---|---|---|
Property 'data' does not exist on type '{ status: "loading" }' | 访问了当前形态没有的字段 | 先在 case 中按判别字段收窄 |
| 收窄完全不生效,类型仍是整个联合 | 判别字段不是字面量类型(写成了 string) | 改用 "a" | "b" 字面量联合 |
default 里的 never 赋值报错 | 联合新增成员但漏了分支 | 补上缺失的 case |
| 联合成员数爆炸、难以维护 | 把彼此独立的字段硬拆成互斥形态 | 改回普通 interface + 可选字段 |
| 收窄只在函数体内有效,回调里失效 | 闭包捕获的变量会被重新拓宽 | 先把值赋给局部常量再用(见 7.3) |
| 从 JSON 解析出的对象直接当判别联合用 | 运行期数据没有类型保证 | 用 Zod 等做边界校验(见 13.2) |
最后一条尤其要提醒:判别联合描述的是编译期的形态,它不会自动校验外部数据。JSON.parse 的返回值是 any,你把它断言成判别联合,编译器会照单全收,运行期该崩还是崩。真正的防线是在边界处做运行期校验,这属于 13.2 Zod 模式验证与类型推导
的主题,本站的 运行时校验与类型安全
也有更完整的讨论。
与类型守卫的关系
判别联合之所以能收窄,本质上依赖的是控制流分析:编译器分析 switch (state.status) 与各个 case 的字面量比较,推断出每个分支里 state 的具体类型。这套机制不仅适用于 switch,也适用于 if、&&、三元表达式,并且不只认判别字段——typeof、instanceof、in、自定义守卫都能触发收窄。
换句话说,判别联合是「让编译器自己推断」,而类型守卫是「你告诉编译器」。当数据结构本身已经带上了可区分的字段,判别联合是零成本的首选;当区分依据需要运行期计算(比如判断一个对象是否实现了某个接口的全部方法),就得自己写守卫。下一节 7.3 类型守卫与控制流分析 会把这两条路线完整讲透。
小结
本节的核心是把「一堆可选字段」升级为「若干互斥形态」。判别联合的三要件是:对象类型的联合、同名的公共判别字段、互不重复的字面量取值。三者齐备后,switch 或 if 中对判别字段的比较会让编译器自动收窄,分支内访问专属字段不再需要可选链或断言。
更重要的是穷尽性检查:在 default 分支里把值赋给 never,就能让「新增成员却漏处理」从运行期 bug 变成编译期错误。这是判别联合相对可选字段最不可替代的价值。
不过判别联合解决的是「有天然区分字段」的场景。当区分依据不体现在数据结构上时,我们需要主动进行运行期检查,并把检查结果告诉编译器——这就是下一节要讲的类型守卫与控制流分析。
阅读导航:上一节:7.1 联合类型与字面量类型 · 下一节:7.3 类型守卫与控制流分析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。