可辨识联合(也叫标签联合,tagged union)是一组对象类型组成的联合,它们都有一个共同的属性,也就是标签,而每个成员中这个属性的字面量值都不同。检查标签就能告诉 TypeScript 你拿到的是哪个成员,于是这个成员的其他属性就可以使用了。
在 case "circle" 里读取 shape.width 会是一个编译错误,因为圆形成员没有 width。标签就是普通的数据:在运行时它只是一个字符串属性,switch 也只是普通的 JavaScript。
什么样的联合才是可辨识的
满足以下三个条件时,根据标签收窄才会生效:
- 每个成员都是对象类型。
- 每个成员都有同名的属性(判别属性)。名字由你决定:
kind、type、status和tag都很常见。 - 在每个成员中,这个属性都是字面量类型:字符串、数字或布尔字面量,或者
null/undefined。
type UiEvent =
| { type: "click"; x: number; y: number }
| { type: "keypress"; key: string }
| { type: "scroll"; delta: number };
type ApiResponse =
| { ok: true; data: string } // boolean literal tags
| { ok: false; error: string };
type Message =
| { version: 1; text: string } // number literal tags
| { version: 2; text: string; lang: string };
如果某个成员把标签声明为普通的 string 而不是字面量,比较标签就不再能收窄这个联合,成员特有的属性也就无法访问(TS2339)。
根据标签收窄
TypeScript 能理解的任何针对标签的检查都能收窄对象:switch、if/else、=== 和 !==,甚至解构出来的标签也可以,只要它是 const:
function describe(e: UiEvent): string {
if (e.type === "click") return `click at ${e.x},${e.y}`;
const { type } = e; // destructured tags narrow too
if (type === "keypress") return `key ${e.key}`;
return `scroll by ${e.delta}`; // only "scroll" is left
}
用 "radius" in shape 检查属性是否存在也能收窄,但比较标签读起来更清楚,而且可以进行穷尽性检查。
穷尽性检查
这种模式最大的好处在联合类型扩展时体现出来。写明返回类型且不写 default 时,TypeScript 知道 switch 必须覆盖每一个标签,所以新增一个成员却没有对应的 case 就是编译错误:
index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
这条信息没有说明缺的是哪个 case。assertNever 辅助函数能说明,而且如果来自外部的数据(JSON、旧版客户端)带着一个类型上不可能存在的标签,它还会在运行时抛出异常:
新增第五种状态却不写对应的 case,assertNever(state) 那一行就会报告 Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never',并指出是哪个成员。更多内容请看 never 页面。
让不可能的状态无法出现
上面的 RequestState 取代了一种常见但较弱的结构:
// Every combination is allowed, including nonsense
type LooseState<T> = {
loading: boolean;
data?: T;
error?: string;
};
const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };
使用可选字段时,“正在加载、同时有数据又有错误”也能通过类型检查,每个读代码的人都得去猜哪些组合真的会出现。使用可辨识联合时,data 只存在于 success 状态,error 只存在于 error 状态,所以读取 state.data 的代码必须先证明自己处于 success 状态。类型本身就说明了哪些状态是有效的。
建模结果:成功或失败
表示“成功了还是没成功”,一个布尔标签就够了。对于输入无效这类预料之中的失败,这个 Result 类型是抛出异常之外的一种常用选择:
调用者不先检查 result.ok 就无法读取 result.value,而这正是使用异常或返回 null 时很容易忘记的检查。
状态机和 reducer
两个可辨识联合,一个表示状态、一个表示动作,就能描述一个状态机。reducer 根据动作进行 switch,并返回下一个状态;编译器会检查每个返回的对象都是有效的状态:
{ ...state, name: "paused" } 之所以能通过编译,是因为 state 已经先被收窄为播放中的成员,所以副本带有 track 和 position。只返回 { name: "paused" } 会报错:暂停状态需要一个曲目。Redux 的 reducer 和 React 中的 useReducer 用的也是这种结构。
类型放宽的陷阱
标签必须保持为字面量类型。如果把对象赋给一个没有注解的变量,它的标签会被放宽为 string,然后就不再匹配任何成员:
type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;
const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.
const ok1: Shape = { kind: "circle", radius: 2 }; // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const; // or keep the literal with as const
area({ kind: "circle", radius: 2 }); // or build it where Shape is expected
背后的规则(可变属性会被放宽)在字面量类型页面中有解释。
常见问题
TypeScript 中的可辨识联合是什么?
它是一组对象类型组成的联合,每个成员都有同一个属性(判别属性,也叫标签),但字面量类型各不相同,例如 { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }。检查 shape.kind === "circle" 会把 shape 收窄为圆形成员,于是它的其他属性就可以使用了。
联合类型和可辨识联合有什么区别?
可辨识联合就是多了一条规则的联合类型:所有成员共享一个具有唯一字面量类型的属性。像 Cat | Fish 这样的普通联合类型必须用 in 或自定义类型守卫来收窄;可辨识联合只要比较一个属性就能收窄,而且对这个属性的 switch 可以进行穷尽性检查。
如何让可辨识联合上的 switch 做到穷尽?
一种方法是给函数写明返回类型且不写 default(缺少某个 case 时就会报错 TS2366);另一种是加上 default: return assertNever(value),并定义 function assertNever(x: never): never { throw ... }。第二种写法会在错误信息中指出未处理的成员,而且在运行时遇到意外数据时也会抛出异常。
为什么我的可辨识联合没有收窄?
标签在每个成员中都必须是字面量类型。如果一个对象创建时没有注解,它的 kind 会被放宽为 string,从而不再匹配任何成员(TS2345/TS2322)。用注解、as const,或者在需要联合类型的地方直接创建它,都可以解决。如果某个成员的类型写成 kind: string,这个联合也无法再根据 kind 收窄。
判别属性可以是布尔值或数字吗?
可以。任何字面量类型都行:字符串(kind: "circle")、数字(version: 2)、布尔值(ok: true / ok: false),甚至 null 或 undefined。字符串标签最常见,因为在日志里或作为 JSON 发送时都很好读。