你重構了一個 shape type,加了一個新的 variant,TypeScript 依然顯示綠燈。CI 過了。Deploy 出去了。結果使用者踩到一個 runtime branch,回傳了 undefined,你的 app 就在 production 炸了。

罪魁禍首幾乎肯定是一個 switch statement。TypeScript 的 switch 沒有 exhaustiveness guarantee。往 union 加一個新 member,所有針對那個 union 的 switch 都會默默變得不完整。Compiler 不會攔你,甚至不會警告你。這正是讓人栽跟頭的地方:TypeScript 明明知道所有可能性,但 switch statements 不參與 exhaustiveness checking。

Exhaustiveness checking 到底是什麼意思

Exhaustiveness 的意思是 compiler 可以證明每一個可能的值都被處理到了。Rust 和 OCaml 這類語言會在 compile time 強制執行。TypeScript 不會,至少對 switch statements 不會。

來看一個 discriminated union:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle is missing — and TypeScript is fine with it
  }
}

這段 code 可以 compile。但當 shape.kind === "triangle" 時,它會回傳 undefined。Function signature 承諾回傳 number,但 implementation 沒有做到。TypeScript 不會標出缺少的 case,因為 switch block 不會被檢查是否完整。

Object lookup pattern

最簡單的替代方案是一個單純的 object map。每個 key 對應到一個 handler function。你用 discriminant 去 index 這個 object,compiler 就會檢查 key 是否存在。

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

這樣好多了。如果你把 "polygon" 加進 Shape,TypeScript 會抱怨 areaHandlers 少了一個 key。Error 指向正確的位置,而且發生在 compile time。

但這些 casts 很醜。我們可以做得更好。

不用 casting 的 type-safe handlers

訣竅是把每個 variant 對應到一個直接接收 narrowed type 的 function。你用 mapped type 來保留 key 和特定 shape variant 之間的關係。

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

現在每個 handler 收到的都是正確 narrowed 的 argument。Function 內部不需要手動 casts。最後一行的 as never 是一個小瑕疵,但它很局部且安全。真正的價值在於 areaHandlers 必須包含 ShapeKind 的每一個 key。拿掉一個,或是往 Shape 加一個新 variant,build 就會掛掉。

用 never assert 做 exhaustiveness checking

有些團隊偏好保留 switch,但加上 compile-time 的 exhaustiveness guard。這是個務實的折衷方案。你加一個接收 never 的 default branch,強迫 compiler 去驗證所有 case 都被處理了。

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

如果你加了一個新的 shape kind 卻忘了加 case,default branch 會收到一個非 never 的值。TypeScript 會報錯,因為你不能把 concrete type assign 給 never。Error 指向 switch,正是你想要的位置。

這個 pattern 在 Sentry 和其他大型 TypeScript codebases 中被廣泛使用。它不需要改變你 code 的結構,只是讓 compiler 為你工作,而不是跟你作對。

用第三方 library 做 pattern matching

如果你想要更接近 Rust 的 match,像 ts-pattern 這樣的 libraries 提供了內建 exhaustiveness checking 的 pattern matching。

import { match, P } from "ts-pattern";

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

.exhaustive() 的呼叫是關鍵。如果少了某個 variant,TypeScript 會回報 error。這個 library 也支援 nested patterns、guards 和 wildcards,可以取代複雜的 nested switch blocks。

這很強大,但它增加了一個 dependency。對於單一的 discriminated union 來說,ts-pattern 太殺雞用牛刀了。但對於有大量 pattern matching 的 codebases,bundle size 是值得的。

Switch statements 仍然佔上風的地方

Switch 不總是錯的。如果你的 cases 需要 fall through,或者邏輯是 imperative 的,有 early returns 和 side effects,那 switch 可能比 lookup table 或一串 .with() 呼叫更容易閱讀。

問題不在語法,而在於缺少 exhaustiveness checking。如果你在用 switch,就加上 assertNever guard。只要兩行 code,就能補上安全缺口。

如何在不重寫一切的情況下採用

你不需要今天就重構所有的 switches。以下是實際的操作順序:

  1. assertNever 加進你的 shared utilities。在新的 switch blocks 中使用它。
  2. 當你改到現有的、針對 unions 的 switches 時,順手加上 guard。
  3. 針對 discriminated unions 的新 code,如果邏輯是 pure 的而且每個 case 彼此獨立,優先使用 object-map pattern。
  4. 如果你發現自己寫 nested switches 或複雜的 matching logic 超過幾次,就評估一下 ts-pattern

目標不是消滅 switch statements,而是讓 compiler 在使用者踩到之前就先抓到 bug。

如果你想親眼看看,試著從 AreaMap 範例中刪掉一個 handler,然後看 tsc 怎麼抱怨。那條紅色波浪線,就是寧靜的週五 deploy 和週末 pagerduty 的差別。