你新增了一個狀態。你忘記了處理程式。沒有任何警告。
state machine一開始很乾淨。三個值,三個 switch 分支。然後重試邏輯加入了第四個狀態。部分失敗加入了第五個。你更新了 reducer,卻漏掉了狀態徽章元件、analytics mapper 和匯出格式化程式。
一切都能編譯通過。這個 bug 在兩個衝刺之後才浮現,當一位使用者在某個你從未手動測試過的邊角案例中碰到空白畫面。
TypeScript 可以讓這種事情不可能發生。不是用 linter 規則。不是用測試。而是用類型系統本身。
什麼是窮舉檢查
窮舉檢查(exhaustiveness checking)是編譯器證明你處理了類型的每一個變體。Rust 和 OCaml 預設就會這樣做。TypeScript 也有這個能力,但你需要主動要求。
這個技巧結合了兩件事:判別聯集(discriminated union),以及一個只接受 never 類型的輔助函式。
判別聯集如何建模狀態
判別聯集是一種 TypeScript 類型,其中每個變體都擁有一個共用的字面屬性,稱為判別屬性(discriminant)。對於state machine來說,這幾乎總是一個 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 字面量都不同。
這勝過字串列舉(string enum)和布林旗標大雜燴。但它還不是窮舉的。刪掉 error 案例,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) 可以編譯通過。
如果你新增了一個狀態卻忘記處理某個案例,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 的函式都會收到各自的錯誤。編譯器不會讓你拖延這項工作。
這個模式可擴展到任何聯集類型
這不僅限於state machine。任何判別聯集都能從同樣的處理方式中受益。
事件處理程式:
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 都會爆炸,直到你處理它為止。
這在哪裡會失效,以及該怎麼辦
窮舉檢查不是魔法。在到處採用之前,先了解它的鋒利邊緣。
你必須使用判別聯集
這對普通的字串列舉或布林旗標無效。編譯器需要一個共用的字面屬性來進行縮窄。
// 這對窮舉檢查無效
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
}
assertNever 只能在封閉聯集上證明窮舉性。對於列舉,編譯器會假設任何相符的字串都是有效的。它無法證明你遺漏了某個案例。
你不能使用 any 或類型斷言
如果你透過 as State 轉型,或從 any 型別的回應中拉取資料,編譯器會跳過縮窄。在邊界進行驗證,然後在內部信任類型。
assertNever 在執行階段作為最後手段拋出例外
這個函式首先是編譯時防護,其次是執行階段防護。理論上你永遠不會觸發那個 throw。實務上,一個轉型或過時的 JSON 會讓它成為你的安全網。這總比默默回傳 undefined 來得好。
分支過多時會變得很吵
新增第十一個案例會觸發每個對該類型做 switch 的函式中的錯誤。這正是重點。但如果你的聯集成長到超過六或七個變體,請考慮將它拆分成更小的子聯集或獨立的state machine。
如何在不重寫一切的情況下引入它
從造成最多 bug 的state machine開始。
- 找到一個型別為
string或寬泛列舉的status欄位。將它替換為封閉聯集。 - 在對它做 switch 的函式中加入
assertNever。 - 讓編譯器引導你。
- 在程式碼審查清單中加入一項要求,規定新的 reducer 必須使用
assertNever。
即使使用 XState,你仍然會撰寫對狀態值做 switch 的 mapper 和 selector。那些函式仍然需要窮舉檢查。
常見問題
這對 if/else 也有效嗎,還是只能用在 switch?
可以,但很脆弱。你需要一個最終的 else 來呼叫 assertNever。重新排列條件或加入提前回傳,你可能會意外漏掉這個檢查。switch 更安全,因為 default 分支很明顯。
如果我在某個特定函式中真的不在乎某些狀態呢?
明確地處理它們。不要讓它們意外地漏到 assertNever。
case "retrying":
// 在此情境中無操作
return ""
編譯器強迫你讓這個決定顯而易見。這正是重點。
我可以從 assertNever 回傳值嗎?
回傳 never 的函式可以指派給任何回傳類型,因為它實際上從不回傳。如果你想要一個 fallback 而不是拋出例外:
function assertNever(value: never, fallback: string): string {
console.error("Unhandled state:", value)
return fallback
}
value: never 參數保留了編譯時檢查的完整性。
這在 JavaScript 中有效嗎?
不行。這是 TypeScript 的編譯時特性。在執行階段,assertNever 只是一個會拋出的函式。安全性來自於型別檢查器拒絕建置可能觸及它的程式碼。
讓編譯器成為你的第二位審查者
每當你新增一個狀態,你就會在 reducer、UI、analytics pipeline、匯出邏輯中創造工作。預設的結果是你漏掉了其中一個地方,然後發布,之後才發現。
窮舉檢查翻轉了預設結果。編譯器變成一位永遠不會忘記檢查 switch 語句的審查者。它不能取代測試,但它能捕捉到一類測試很少覆蓋的 bug:那種你根本不知道自己需要寫程式碼的情況。
加入這個輔助函式。在一個 reducer 中使用它。下次你擴展state machine時,讓 TypeScript 告訴你工作在哪裡。它不會漏掉任何一個分支。