你添加了一个状态。你忘记了处理函数。没有任何警告。
状态机起初很干净。三个值,三个 switch 分支。然后重试逻辑添加了第四个状态。部分失败添加了第五个。你更新了 reducer,却漏掉了状态徽标组件、分析映射器和导出格式化器。
一切都能编译通过。两个冲刺周期后,当用户在你从未手动测试过的边界场景中遇到空白屏幕时,bug 才浮出水面。
TypeScript 可以让这成为不可能。不是靠 linter 规则。不是靠测试。而是靠类型系统本身。
穷尽性检查到底意味着什么
穷尽性检查是编译器证明你处理了类型的每一种变体。Rust 和 OCaml 默认就具备此功能。TypeScript 也有,但你需要主动要求。
这个技巧结合了两样东西:可辨识联合(discriminated union),以及一个只接受 never 类型的辅助函数。
可辨识联合如何对状态建模
可辨识联合是一种 TypeScript 类型,其中每个变体都有一个共享的字面量属性,称为判别式(discriminant)。对于状态机来说,这几乎总是一个 status、kind 或 type 字段。
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
每个变体只携带与该状态相关的数据。idle 上没有可选的 data 字段,success 上没有 message 字段。对象的形状告诉你处于哪个状态,而编译器会在 switch 内部自动收窄类型。
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
}
}
在 case "loading" 内部,类型是 { status: "loading"; requestId: string },因此 state.requestId 是可用的。编译器自动收窄,因为每个变体的 status 字面量都不同。
这优于字符串枚举和布尔标志大杂烩。但它还不是穷尽的。去掉 error case,TypeScript 仍然能编译。函数隐式返回 undefined,而编译器保持沉默。
强制穷尽处理的 never 技巧
这是辅助函数:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}
这个函数只接受 never,即空联合,没有值的类型。除非你欺骗了编译器,否则不可能用任何真实值调用 assertNever。
现在把它加到你的 switch 底部:
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
default:
return assertNever(state)
}
}
如果 State 的每一种可能变体都在 default 之上处理了,那么 default 分支内的 state 就被收窄为 never。调用 assertNever(state) 可以编译通过。
如果你添加了新状态却忘记了 case,default 分支就会收到一个真实类型。你不能把真实类型传给 never 参数。编译器会抛出类型错误。
在实践中观察它报错
添加一个 retrying 状态:
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
| { status: "retrying"; attempt: number; lastError: string }
现在 handleState 编译失败:
Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.
错误直接指向 default 分支。不是运行时崩溃。而是编译时拒绝构建,直到你决定了 retrying 在这个特定函数中意味着什么。
每个对 State 进行 switch 的函数都会得到自己的错误。编译器不会允许你推迟这项工作。
这个模式可以扩展到任何联合类型
这不局限于状态机。任何可辨识联合都能从同样的处理中受益。
事件处理器:
type Event =
| { type: "USER_LOGIN"; userId: string }
| { type: "USER_LOGOUT" }
| { type: "PAGE_VIEW"; path: string }
function handleEvent(event: Event): void {
switch (event.type) {
case "USER_LOGIN":
trackLogin(event.userId)
return
case "USER_LOGOUT":
trackLogout()
return
case "PAGE_VIEW":
trackPageView(event.path)
return
default:
assertNever(event)
}
}
Redux 风格的 reducer:
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount
case "decrement":
return state - action.amount
case "reset":
return 0
default:
return assertNever(action)
}
}
带有字面量判别式的对象联合。对它进行 switch。默认使用 assertNever。添加一个变体,每个 switch 都会报错,直到你处理它。
它在何处失效以及如何应对
穷尽性检查不是魔法。在到处采用之前,先了解它的锋利边缘。
你必须使用可辨识联合
这对普通字符串枚举或布尔标志不起作用。编译器需要一个共享的字面量属性来进行收窄。
// This does NOT work for exhaustiveness checking
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
}
assertNever 只能在封闭联合上证明穷尽性。对于枚举,编译器假设任何匹配的字符串都是有效的。它无法证明你遗漏了某个 case。
你不能使用 any 或类型断言
如果你通过 as State 进行类型转换,或者从 any 类型的响应中提取数据,编译器会跳过收窄。在边界处进行验证,然后信任内部的类型。
assertNever 作为最后的手段在运行时抛出
这个函数首先是编译时守卫,其次是运行时守卫。理论上你永远不会走到 throw。实践中,一次类型转换或过时的 JSON 会让它成为你的安全网。这比静默返回 undefined 要好。
分支过多时会变得嘈杂
添加第十一个 case 会在每个对该类型进行 switch 的函数中触发错误。这正是重点。但如果你的联合类型增长到六七个变体以上,考虑将它拆分成更小的子联合或独立的状态机。
如何在不重写一切的情况下引入它
从引发最多 bug 的状态机开始。
- 找到一个类型为
string或宽松枚举的status字段。用封闭联合替换它。 - 在对它进行 switch 的函数中添加
assertNever。 - 让编译器引导你。
- 添加一份代码审查清单,要求在新的 reducer 中使用
assertNever。
即使使用 XState,你仍然需要编写对状态值进行 switch 的 mapper 和 selector。这些函数仍然需要穷尽性检查。
常见问题
这能用 if/else 代替 switch 吗?
可以,但很脆弱。你需要一个最终的 else 来调用 assertNever。重新排序条件或添加提前返回,你可能会意外丢掉检查。switch 更安全,因为 default 分支是显眼的。
如果我在某个特定函数中确实不关心某些状态怎么办?
显式处理它们。不要意外让它们落入 assertNever。
case "retrying":
// No-op in this context
return ""
编译器迫使你让决策可见。这正是重点。
我能从 assertNever 返回值吗?
返回 never 的函数可以赋值给任何返回类型,因为它实际上永远不会返回。如果你想要一个回退而不是抛出:
function assertNever(value: never, fallback: string): string {
console.error("Unhandled state:", value)
return fallback
}
value: never 参数保持了编译时检查的完整性。
这在 JavaScript 中有效吗?
不。这是 TypeScript 的编译时特性。在运行时,assertNever 只是一个抛出的函数。安全性来自类型检查器拒绝构建可能到达它的代码。
让编译器成为你的第二位审查者
每次你添加一个状态,你都会在 reducer、UI、分析管道、导出逻辑中创造工作。默认的结果是你漏掉了其中一个地方,发布出去,之后才发现。
穷尽性检查扭转了默认局面。编译器变成了一位永远不会忘记检查 switch 语句的审查者。它不能替代测试,但它捕获了一类测试很少覆盖的 bug:那种你只是不知道必须写代码的情况。
添加这个辅助函数。在一个 reducer 中使用它。下次你扩展状态机时,让 TypeScript 告诉你工作在哪里。它不会漏掉一个分支。