TypeScript 穷尽性检查的工程化落地:用 never 类型与可辨识联合打造零遗漏的状态机
前端应用里最隐蔽的 bug,往往不是逻辑写错,而是漏写了某个状态分支。当需求迭代为状态枚举新增一个取值时,散落在各处的 switch、if/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} />';
}
这段代码有两个致命问题:
- 状态非法组合:
loading: true且error同时有值,类型上完全合法,运行时却会出现「既加载中又报错」的荒诞界面。 - 分支遗漏无法预警:哪天你新增了
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等判别字段打造可辨识联合,避免非法状态组合; - 在
switch的default分支里用never断言收口,让漏写分支直接编译报错; - 优先用
Record<判别字段, Handler>做纯函数分发,获得更强的穷尽性约束; - 把
assertNever提取为团队公共工具,统一异常兜底格式。
当下次需求方说「再加一个 pending 状态」,你会发现改一行类型定义,编译器会替你把所有漏改的分支逐一指出来——这才是类型系统真正的工程化价值。
评论区
登录 后参与评论