你新增了一個狀態。你忘記了處理程式。沒有任何警告。

state machine一開始很乾淨。三個值,三個 switch 分支。然後重試邏輯加入了第四個狀態。部分失敗加入了第五個。你更新了 reducer,卻漏掉了狀態徽章元件、analytics mapper 和匯出格式化程式。

一切都能編譯通過。這個 bug 在兩個衝刺之後才浮現,當一位使用者在某個你從未手動測試過的邊角案例中碰到空白畫面。

TypeScript 可以讓這種事情不可能發生。不是用 linter 規則。不是用測試。而是用類型系統本身。

什麼是窮舉檢查

窮舉檢查(exhaustiveness checking)是編譯器證明你處理了類型的每一個變體。Rust 和 OCaml 預設就會這樣做。TypeScript 也有這個能力,但你需要主動要求。

這個技巧結合了兩件事:判別聯集(discriminated union),以及一個只接受 never 類型的輔助函式。

判別聯集如何建模狀態

判別聯集是一種 TypeScript 類型,其中每個變體都擁有一個共用的字面屬性,稱為判別屬性(discriminant)。對於state machine來說,這幾乎總是一個 statuskindtype 欄位。

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開始。

  1. 找到一個型別為 string 或寬泛列舉的 status 欄位。將它替換為封閉聯集。
  2. 在對它做 switch 的函式中加入 assertNever
  3. 讓編譯器引導你。
  4. 在程式碼審查清單中加入一項要求,規定新的 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 告訴你工作在哪裡。它不會漏掉任何一個分支。