可辨识联合(Discriminated Union)在 TypeScript 社区有一个更常见的叫法——标签联合。它的核心思想很简单:一个类型可以是一组互斥类型的并集,每个成员通过一个字面量字段("标签")来区分。
但实际项目里经常见到一种写法:手动写一堆 if 去判断类型,漏掉某个分支却不报错,等到运行时才发现那个分支没处理。这不是可辨识联合的问题,而是没有用上 TypeScript 的穷举检查能力。
可辨识联合的三个要素
一个合法的可辨识联合需要满足三个条件:
- 有一个公共的、字面量类型的字段(辨识标签)
- 每个成员在这个字段上的值不一样
- 标签字段的类型是字面量(
'idle'而不是string)
// 不符合条件——status 是 string,不是字面量类型
type StateBad = {
status: string
error?: string
data?: unknown
}
// 可辨识联合
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string }
| { status: 'error'; error: string }
第三个成员 'success' 额外带了一个 data 字段,第四个成员 'error' 带了 error 字段。其他成员上没有这些字段。这就是互斥——当 status 是 'loading' 时,data 和 error 都不存在。
类型收窄的机制
当 you 用 if 或 switch 检查标签字段时,TypeScript 会自动收窄联合类型到具体的成员:
function handleState(state: State) {
switch (state.status) {
case 'idle':
// state: { status: 'idle' }
break
case 'loading':
// state: { status: 'loading' }
break
case 'success':
// state: { status: 'success'; data: string }
console.log(state.data.toUpperCase())
break
case 'error':
// state: { status: 'error'; error: string }
console.error(state.error)
break
}
}
编译器在这个 switch 的每个分支里,都能精确知道当前是哪一种状态。这不是模式字符串匹配,而是控制流分析——TypeScript 会跟踪 state.status 的值在不同代码路径上的变化。
但有一个问题:如果新增了一个状态 'paused',但忘记在 switch 里加对应的分支,编译器不会报错。这就需要穷举检查。
穷举检查的实现
穷举检查的常用方式是在 default 分支里赋值给一个 never:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`)
}
function handleStateExhaustive(state: State) {
switch (state.status) {
case 'idle':
case 'loading':
break
case 'success':
console.log(state.data)
break
case 'error':
console.error(state.error)
break
default:
assertNever(state) // 如果走了这里,说明有未覆盖的分支
}
}
如果之后给 State 增加了 { status: 'paused' },但没有在 switch 里加 case 'paused',编译器会在 assertNever(state) 这行报错——因为 state 的类型在 default 分支里变成了 { status: 'paused' },不能赋值给 never。
这个方法依赖一个类型系统的事实:never 是 bottom type,只有 never 可以赋值给 never。任何非 never 的类型赋值给 never 都会触发编译错误。
实战 1:有限状态机
前端 UI 里的有限状态机是可辨识联合最自然的应用场景:
type FetchState<T> =
| { status: 'idle' }
| { status: 'pending' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; retryCount: number }
function renderState<T>(state: FetchState<T>, onRetry: () => void) {
switch (state.status) {
case 'idle':
case 'pending':
return '<Spinner />'
case 'success':
return `<DataView data={state.data} />`
case 'error':
return `<ErrorView error={state.error} onRetry=${onRetry} />`
}
}
这种模式的好处是:状态迁移必须在编译期约束内进行。你不能在 'idle' 状态下访问 state.data,编译器不让你通过。状态之间的转换也是显式的:
function transition(state: FetchState<string>, action: Action): FetchState<string> {
switch (state.status) {
case 'idle':
if (action.type === 'FETCH') return { status: 'pending' }
return state
case 'pending':
if (action.type === 'SUCCESS') return { status: 'success', data: action.data }
if (action.type === 'ERROR') return { status: 'error', error: action.error, retryCount: 0 }
return state
case 'success':
case 'error':
if (action.type === 'RETRY') return { status: 'pending' }
return state
}
}
实战 2:多类型 API 响应
后端 API 返回的数据经常是"成功时返回 data,失败时返回 error"——但这是一种隐式的联合。用可辨识联合可以显式表达:
type ApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } }
function handleApiResponse<T>(res: ApiResponse<T>) {
if (res.ok) {
// res: { ok: true; data: T }
return res.data
}
// res: { ok: false; error: { code: number; message: string } }
console.error(`Error ${res.error.code}: ${res.error.message}`)
}
用 ok: true 和 ok: false 作为标签比 status: 'success' | 'error' 更简洁——在 if 分支里,true 分支收窄到成功,false 分支(或 else)收窄到错误。
实战 3:表单字段类型
表单字段可以有很多种类型:文本、数字、选择框、日期等。用可辨识联合可以精确建模:
type FormField =
| { type: 'text'; value: string; placeholder?: string }
| { type: 'number'; value: number; min?: number; max?: number }
| { type: 'select'; value: string; options: string[] }
| { type: 'checkbox'; checked: boolean; label: string }
function renderField(field: FormField): string {
switch (field.type) {
case 'text':
return `<input type="text" value="${field.value}" />`
case 'number':
return `<input type="number" value="${field.value}" min="${field.min ?? ''}" />`
case 'select':
return `<select>${field.options.map(o => `<option>${o}</option>`)}</select>`
case 'checkbox':
return `<input type="checkbox" ${field.checked ? 'checked' : ''} />`
}
}
每种字段类型携带自己的专属属性——select 有 options,number 有 min/max,checkbox 有 checked。如果混成一个对象,要么所有可选字段都打上 ?,要么就得接受某些字段在语义上不存在但类型上仍然可访问。
嵌套的可辨识联合
有些场景下,标签字段的值本身还有层级结构:
type Notification =
| { kind: 'email'; to: string; subject: string }
| { kind: 'sms'; phone: string }
| { kind: 'push'; deviceToken: string; title: string }
type Channel = 'email' | 'sms' | 'push'
type ChannelConfig = {
[K in Channel]: {
kind: K
enabled: boolean
config: Extract<Notification, { kind: K }>
}
}
这里 Extract<Notification, { kind: K }> 从联合类型中提取出匹配 kind 的成员,保证配置对象里的 config 类型和 kind 一致。
可辨识联合 vs 可选字段
多人写的类型是可选字段铺开:
type StateBad = {
status: 'idle' | 'loading' | 'success' | 'error'
data?: string
error?: string
}
这种写法的问题:data 和 error 在 'idle' 和 'loading' 状态下是 undefined,但编译器不会阻止你在 'idle' 状态下读 data——最多是读到 undefined。而且如果 'success' 状态下你忘了赋值 data,编译器也不会报错。
可辨识联合方案强迫你在每个分支里处理对应数据——不能多,不能少。
| 方案 | 类型安全 | 可扩展性 | 运行时检查 |
|---|---|---|---|
| 可选字段 | 弱——字段可读但可能为空 | 添加新状态只需加值 | 需要运行时验证 |
| 可辨识联合 | 强——只读当前状态的字段 | 添加新状态需加新成员 | 编译器确保穷举 |
总结
可辨识联合解决了 JavaScript 中的一个常见问题:隐式状态。一个对象里哪些字段当前有效,完全依赖于某个字段的值——这是一种运行时约定,TypeScript 的可辨识联合把它变成了编译期约束。
使用可辨识联合的三个关键习惯:
- 标签字段必须用字面量类型(
'idle'而不是string),否则联合不会生效 - 始终在
switch的default分支调用assertNever,确保新增分支时不会遗漏 - 优先用可辨识联合替代可选字段平铺,尤其是状态切换明显的场景


