前端开发··2 阅读·预计 10 分钟

TypeScript 穷尽性检查的工程化落地:用 never 类型与可辨识联合打造零遗漏的状态机

前端应用里最隐蔽的 bug,往往不是逻辑写错,而是漏写了某个状态分支。当需求迭代为状态枚举新增一个取值时,散落在各处的 switchif/else 不会提示你「这里该补一个分支」,直到某个用户触发了那条支路,白屏或静默失效才暴露出来。

TypeScript 提供了一套机制,能把这类「分支遗漏」从运行时错误前移为编译期报错:可辨识联合(Discriminated Union)配合 never 类型的穷尽性检查。本文通过一个真实的多态状态机场景,给出正反例对比与可落地的工程化规范。

一个典型的状态机:HTTP 请求的生命周期

假设我们管理一个异步请求的状态,它可能处于四种状态之一。先用最常见的「松耦合」方式建模:

// ❌ 反例:字段离散、无判别字段
type RequestState = {
  loading: boolean;
  data: User | null;
  error: string | null;
};

function render(state: RequestState) {
  if (state.loading) {
    return '<Spinner />';
  }
  if (state.error) {
    return '<ErrorTip msg={state.error} />';
  }
  return '<UserCard user={state.data} />';
}

这段代码有两个致命问题:

  1. 状态非法组合loading: trueerror 同时有值,类型上完全合法,运行时却会出现「既加载中又报错」的荒诞界面。
  2. 分支遗漏无法预警:哪天你新增了 cached 状态(如 SWR 的缓存命中),render 里的 if 链既不会报错,也不会告诉你缺了缓存分支,用户会在缓存命中时看到空页面。

正解:可辨识联合 + 穷尽性检查

status 作为判别字段(discriminant),把互斥状态建模为联合类型:

type RequestState =
  | { status: 'idle' }
  | { status: 'loading'; progress: number }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };

关键在渲染函数的默认分支上做穷尽性断言:

function render(state: RequestState): string {
  switch (state.status) {
    case 'idle':
      return '<EmptyView />';
    case 'loading':
      return `<ProgressBar value=${state.progress} />`;
    case 'success':
      return `<UserCard user={state.data} />`;
    case 'error':
      return `<ErrorTip msg={state.error} />`;
    default: {
      // ✅ 穷尽性检查:若漏写分支,这里会编译报错
      const _exhaustive: never = state;
      throw new Error(`Unhandled state: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

这里的核心是 const _exhaustive: never = state;。TypeScript 在 switch 已经覆盖所有 case 后,会把 state 收窄为 never 类型;只有当所有分支都处理完毕时,这行赋值才能通过编译。

一旦有人为 RequestState 新增了 { status: 'cached'; data: User }default 分支里的 state 类型就变成了 { status: 'cached' },赋值给 never 会立刻报错:

Type '{ status: "cached"; data: User }' is not assignable to type 'never'.

于是漏写的分支在 CI 阶段就被拦下,而不是等到线上某个用户缓存命中时才炸。

让穷尽性检查更好用:提取 helper

把上面的模式封装成工具函数,避免每处 switch 都手写一遍:

export function assertNever(value: never, context?: string): never {
  throw new Error(
    `Unexpected value${context ? ` in ${context}` : ''}: ${JSON.stringify(value)}`,
  );
}

// 用法
switch (state.status) {
  case 'idle':   return renderIdle();
  case 'loading': return renderLoading(state.progress);
  case 'success': return renderData(state.data);
  case 'error':   return renderError(state.error);
  default:
    return assertNever(state, 'render');
}

assertNever 的入参签名是 never,任何「未收窄干净」的联合成员传入都会触发类型错误,而函数体里的 JSON.stringify 又能在极端情况下提供可读的运行时兜底。

两个常见误区

误区一:用 if/else 链代替 switch 时遗漏判别字段。

// ❌ 反例:只判一个字段,无法触发穷尽性
type State = { kind: 'a' | 'b'; } | { kind: 'c'; value: number };

function handle(s: State) {
  if (s.kind === 'a') return 1;
  if (s.kind === 'b') return 2;
  // 漏了 kind === 'c',TS 不会报错,因为函数隐式返回 undefined
}

if 链不会像 switch 的默认分支那样暴露 never,函数只是隐式返回了 undefined。正确做法是保持 switch 并显式处理 default,或者为函数声明具体返回类型,让「缺少 return 路径」本身成为报错。

误区二:穷尽性检查只认 switch,忽略 Record 映射的好处。

当状态处理是纯函数映射时,用 Record 配合判别字段往往更简洁,且天然穷尽:

const handlers: Record<RequestState['status'], (s: RequestState) => string> = {
  idle:    () => '<EmptyView />',
  loading: (s) => (s.status === 'loading' ? `<Progress ${s.progress} />` : ''),
  success: (s) => (s.status === 'success' ? `<Card ${s.data.name} />` : ''),
  error:   (s) => (s.status === 'error' ? `<Tip ${s.error} />` : ''),
};

// 新增 'cached' 状态时,上面这个对象会直接编译报错:缺少 cached 属性
export function render(s: RequestState) {
  return handlers[s.status](s);
}

通过 Record<RequestState['status'], Handler> 声明完整键集,任何新增的状态取值都会让对象字面量「缺键」而编译失败——这是比 switch 更强的静态约束。

小结

穷尽性检查的价值在于把「分支完整性」这一隐性约定,变成编译器强制执行的显式契约

  • status/kind 等判别字段打造可辨识联合,避免非法状态组合;
  • switchdefault 分支里用 never 断言收口,让漏写分支直接编译报错;
  • 优先用 Record<判别字段, Handler> 做纯函数分发,获得更强的穷尽性约束;
  • assertNever 提取为团队公共工具,统一异常兜底格式。

当下次需求方说「再加一个 pending 状态」,你会发现改一行类型定义,编译器会替你把所有漏改的分支逐一指出来——这才是类型系统真正的工程化价值。

0 评论

评论区

登录 后参与评论