TypeScript 可辨识联合与穷举检查:状态管理的模式匹配实践

HTMLPAGE 团队
18 分钟阅读

可辨识联合是 TypeScript 表达

#TypeScript #可辨识联合 #类型收窄 #模式匹配 #状态管理

可辨识联合(Discriminated Union)在 TypeScript 社区有一个更常见的叫法——标签联合。它的核心思想很简单:一个类型可以是一组互斥类型的并集,每个成员通过一个字面量字段("标签")来区分。

但实际项目里经常见到一种写法:手动写一堆 if 去判断类型,漏掉某个分支却不报错,等到运行时才发现那个分支没处理。这不是可辨识联合的问题,而是没有用上 TypeScript 的穷举检查能力。

可辨识联合的三个要素

一个合法的可辨识联合需要满足三个条件:

  1. 有一个公共的、字面量类型的字段(辨识标签)
  2. 每个成员在这个字段上的值不一样
  3. 标签字段的类型是字面量('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' 时,dataerror 都不存在。

类型收窄的机制

当 you 用 ifswitch 检查标签字段时,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: trueok: 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' : ''} />`
  }
}

每种字段类型携带自己的专属属性——selectoptionsnumbermin/maxcheckboxchecked。如果混成一个对象,要么所有可选字段都打上 ?,要么就得接受某些字段在语义上不存在但类型上仍然可访问。

嵌套的可辨识联合

有些场景下,标签字段的值本身还有层级结构:

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
}

这种写法的问题:dataerror'idle''loading' 状态下是 undefined,但编译器不会阻止你在 'idle' 状态下读 data——最多是读到 undefined。而且如果 'success' 状态下你忘了赋值 data,编译器也不会报错。

可辨识联合方案强迫你在每个分支里处理对应数据——不能多,不能少。

方案类型安全可扩展性运行时检查
可选字段弱——字段可读但可能为空添加新状态只需加值需要运行时验证
可辨识联合强——只读当前状态的字段添加新状态需加新成员编译器确保穷举

总结

可辨识联合解决了 JavaScript 中的一个常见问题:隐式状态。一个对象里哪些字段当前有效,完全依赖于某个字段的值——这是一种运行时约定,TypeScript 的可辨识联合把它变成了编译期约束。

使用可辨识联合的三个关键习惯:

  1. 标签字段必须用字面量类型('idle' 而不是 string),否则联合不会生效
  2. 始终在 switchdefault 分支调用 assertNever,确保新增分支时不会遗漏
  3. 优先用可辨识联合替代可选字段平铺,尤其是状态切换明显的场景