写 TypeScript 的人都遇到过这种代码:函数接受多种形态的对象,分支里用 instanceof 或 'foo' in obj 来判断当前是哪一种。这种写法能跑,但类型推导很多时候不到位,重构时也容易遗漏分支。换成判别联合类型(discriminated union)之后整个世界都清爽了。
一个具体例子
假设我们在写一个 HTTP 客户端,请求结果有三种:成功、业务错误(HTTP 200 但 body 里 code 非 0)、网络错误。早期版本可能长这样:
typescript
interface Result {
data?: unknown
bizError?: { code: number; message: string }
networkError?: Error
}
function handle(r: Result) {
if (r.networkError) {
console.error('network down', r.networkError)
} else if (r.bizError) {
console.warn('biz error', r.bizError.code)
} else {
console.log('ok', r.data)
}
}
问题:
- 三个字段都是可选的,调用方能构造出
{ data: ..., networkError: ... }这种自相矛盾的对象 else分支里 TypeScript 并不能保证r.data一定存在- 加一种新的结果类型时,编译器不会提醒所有
handle函数有遗漏
用判别字段重写
typescript
type Result =
| { kind: 'ok'; data: unknown }
| { kind: 'bizError'; code: number; message: string }
| { kind: 'networkError'; cause: Error }
function handle(r: Result) {
switch (r.kind) {
case 'ok':
console.log('ok', r.data)
return
case 'bizError':
console.warn('biz error', r.code)
return
case 'networkError':
console.error('network down', r.cause)
return
}
}
每个分支里 TypeScript 自动收窄类型:在 case 'ok' 里访问 r.code 会直接报错。三种状态互斥,不可能同时存在。
加上 exhaustiveness check
更进一步,可以加一个 assertNever 让编译器在你忘记处理新分支时直接报错:
typescript
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`)
}
function handle(r: Result) {
switch (r.kind) {
case 'ok': return console.log('ok', r.data)
case 'bizError': return console.warn('biz error', r.code)
case 'networkError': return console.error('network down', r.cause)
default: return assertNever(r)
}
}
后面如果在 Result 里加了一个 { kind: 'timeout'; afterMs: number },所有 handle 函数会立刻在 assertNever(r) 那行编译错误,因为 r 的类型不再是 never。这个模式在大型代码库里非常救命,相当于给类型系统挂了一个"穷尽性"的开关。
什么时候不适合
判别联合不是免费的:
- 如果各个变体之间共享字段很多,用判别字段会有重复
- 如果变体集合是开放的(比如允许第三方扩展),用 class + 接口可能更合适
- 跨模块边界传输时(JSON、protobuf)要确认
kind字段在序列化协议里也能保留
但只要你的"几种状态"是闭集合,判别联合几乎总是比 instanceof 或可选字段更好的选择。