本文 AI 產出,尚未審核

TypeScript ─ 型別推論與型別保護

主題:Discriminated Unions(具判別欄位的聯合型別)


簡介

在大型前端或 Node.js 專案中,資料的形狀往往會隨著業務需求而變化。型別安全是 TypeScript 最核心的價值,但如果只靠靜態的介面(interface)或型別別名(type alias)來描述,往往會失去對「哪個型別目前被使用」的精確判斷。
這時 Discriminated Unions(具判別欄位的聯合型別) 就派上用場:透過一個唯一且永遠存在的「判別欄位」(discriminant),讓編譯器在執行型別保護(type narrowing)時,能自動縮小可能的型別範圍,從而提供 更完整的型別推論、更少的執行時錯誤,以及更好的開發者體驗(自動完成、即時錯誤提示)。

本文將一步步說明什麼是 discriminated union、如何在 TypeScript 中實作、常見的陷阱與最佳實踐,並提供實務範例,幫助你在日常開發中安全、優雅地處理多樣化的資料結構。


核心概念

1️⃣ 什麼是 Discriminated Union?

簡單來說,Discriminated Union 是由多個物件型別組成的聯合型別(union),每個子型別都必須擁有 相同名稱且值唯一的屬性(即「判別欄位」),讓 TypeScript 能根據該欄位的值自動縮小型別。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "rectangle"; width: number; height: number };
  • kind 為判別欄位,值分別是 "circle""square""rectangle"
  • 當我們在程式中檢查 shape.kind 時,編譯器即可 推論 shape 的具體型別。

重點:判別欄位必須是字面值類型(literal type)或 enum,且在每個子型別中不可缺省


2️⃣ 為什麼需要型別保護(Narrowing)?

型別保護是 TypeScript 透過控制流程(if、switch、instanceof 等)自動縮小變數可能型別的機制。若沒有判別欄位,編譯器只能給出 寬鬆的聯合型別,導致屬性存取時必須自行加上型別斷言(as)或非空檢查(!),增加錯誤風險。

function area(shape: Shape) {
  // 沒有判別欄位的情況,無法直接存取 radius、sideLength…
  // 必須寫成 (shape as any).radius 等不安全的寫法
}

使用 discriminated union,switch (shape.kind) 內部的每個分支都會自動得到正確的型別:

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // ✅ shape 已被縮小為 { kind: "circle"; radius: number }
    case "square":
      return shape.sideLength ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      // 這裡永遠不會被執行,因為所有可能已被列舉
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

3️⃣ 建立 Discriminated Union 的步驟

步驟 說明
① 定義判別欄位 建議使用 kindtypestatus 等語意清晰的名稱。值必須是 字面值"A""B")或 enum
② 為每個子型別加入相同的判別欄位 每個子型別的 kind 必須是唯一的字面值,且不可缺省。
③ 使用 type 別名或 interface 組成聯合 `type Shape = Circle
④ 於程式流程中檢查判別欄位 if (shape.kind === "circle") { … }switch,讓 TypeScript 完成型別縮小。

4️⃣ 程式碼範例

範例 1:基本的 Shape 判別

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

function describe(s: Shape): string {
  if (s.kind === "circle") {
    return `圓形,半徑 ${s.radius}`;
  }
  if (s.kind === "square") {
    return `正方形,邊長 ${s.side}`;
  }
  // 這裡 TypeScript 已自動推論為 rectangle
  return `長方形,寬 ${s.width}、高 ${s.height}`;
}

說明if (s.kind === "circle") 讓編譯器把 s 縮小為 { kind: "circle"; radius: number },因此 s.radius 可以直接使用,且不會產生錯誤。


範例 2:使用 enum 作為判別欄位

enum ActionType {
  Add = "ADD",
  Remove = "REMOVE",
  Update = "UPDATE",
}

type Action =
  | { type: ActionType.Add; payload: { id: number; name: string } }
  | { type: ActionType.Remove; payload: { id: number } }
  | { type: ActionType.Update; payload: { id: number; name?: string } };

function reducer(state: Record<number, string>, action: Action) {
  switch (action.type) {
    case ActionType.Add:
      state[action.payload.id] = action.payload.name;
      break;
    case ActionType.Remove:
      delete state[action.payload.id];
      break;
    case ActionType.Update:
      const current = state[action.payload.id];
      if (current && action.payload.name) {
        state[action.payload.id] = action.payload.name;
      }
      break;
  }
  return state;
}

說明enum 同樣提供字面值的唯一性,且在大型專案中能避免硬編碼字串的錯字。switch 內每個分支皆得到相對應的 payload 型別。


範例 3:結合類別(class)與 discriminated union

class Success {
  readonly kind = "success";
  constructor(public data: string) {}
}
class Failure {
  readonly kind = "failure";
  constructor(public error: Error) {}
}
type Result = Success | Failure;

function handle(r: Result) {
  if (r.kind === "success") {
    console.log("✅", r.data); // r 被縮小為 Success
  } else {
    console.error("❌", r.error.message); // r 被縮小為 Failure
  }
}

說明:即使使用 class,只要在每個類別中宣告 只讀readonly)的判別欄位,TypeScript 仍能正確進行型別保護。


範例 4:深層嵌套的 discriminated union

type ApiResponse =
  | { status: "ok"; data: { type: "user"; user: { id: number; name: string } } }
  | { status: "ok"; data: { type: "product"; product: { sku: string; price: number } } }
  | { status: "error"; error: { code: number; message: string } };

function parse(resp: ApiResponse) {
  if (resp.status === "error") {
    throw new Error(`Error ${resp.error.code}: ${resp.error.message}`);
  }

  // 兩種 ok 的情況,需要再根據 data.type 判別
  switch (resp.data.type) {
    case "user":
      return `使用者 ${resp.data.user.name}`;
    case "product":
      return `商品 ${resp.data.product.sku} 價格 ${resp.data.product.price}`;
  }
}

說明:此例展示 多層判別:先根據 status 判斷成功或失敗,再根據 data.type 判斷具體資料形態。只要每層都有唯一的字面值欄位,型別縮小會層層進行。


範例 5:使用 never 保障 exhaustiveness(完整性檢查)

type Event =
  | { kind: "click"; x: number; y: number }
  | { kind: "keypress"; key: string };

function log(e: Event) {
  switch (e.kind) {
    case "click":
      console.log(`點擊座標 (${e.x}, ${e.y})`);
      break;
    case "keypress":
      console.log(`按鍵 ${e.key}`);
      break;
    default:
      // 若未列舉所有可能,編譯器會在此報錯
      const _exhaustive: never = e;
      return _exhaustive;
  }
}

說明default 分支裡的 never 變數會在未涵蓋所有 kind 時產生編譯錯誤,確保 未來若新增新型別 必須同步更新 switch


常見陷阱與最佳實踐

陷阱 描述 解法 / 建議
缺少判別欄位 若子型別未包含共同的 kind,型別縮小失效。 確保每個子型別都有 只讀 (readonly) 的字面值屬性。
判別欄位類型不唯一 使用相同字串或 enum 成員會導致模糊。 為每個子型別分配 唯一 的字面值,或使用 as const 讓字面值保持不可變。
判別欄位可為 undefined 允許缺省或 null 會讓縮小失效。 使用 required(必填)或 readonly,在 interface/型別別名中明確宣告。
在外部函式/API 中遺失判別欄位 從外部 JSON 取得的資料若缺少 kind,編譯器無法保證安全。 在取得資料後 手動驗證(type guard)或使用 zod / io-ts 等 runtime 驗證庫。
使用 any/unknown 逃避檢查 直接把資料斷言為 union 會失去型別保護的好處。 儘量避免 any,使用 unknown + type guardas const 讓編譯器自行判斷。
忘記 exhaustive check 新增子型別卻忘記更新 switch,導致隱藏錯誤。 default 中加入 never 檢查,讓編譯器提醒。

最佳實踐小結

  1. 始終使用 readonly 讓判別欄位不可變。
  2. 把判別欄位放在最外層(或最易取得的位置),減少嵌套查找成本。
  3. 使用 enum 或字面值常數,避免硬編碼字串導致的 typo。
  4. 加入 exhaustiveness 檢查never)以防止遺漏分支。
  5. 結合 runtime 驗證(例如 zod)確保從外部取得的資料符合 discriminated union。

實際應用場景

場景 為什麼適合使用 Discriminated Union
API 回傳多種結果(成功、失敗、驗證錯誤) 判別欄位 statuscode 讓前端直接根據型別處理 UI。
Redux / NgRx 等狀態管理的 Action 每個 Action 皆有 type(enum)與對應 payload,Reducer 可安全地做分支。
表單驗證結果 kind: "valid" / "invalid" + 具體錯誤資訊,使 UI 渲染更簡潔。
圖形繪圖或遊戲引擎的實體 `kind: "player"
多語系訊息或日誌系統 `type: "info"

總結

  • Discriminated Union 為 TypeScript 提供了 結構化的型別保護:只要在每個子型別中加入唯一且必定存在的判別欄位,編譯器即可在 ifswitch 等控制流程中自動 縮小型別,讓屬性存取變得安全、直觀。
  • 使用 enumas constreadonly 可避免常見的字串 typo 與可變性問題。
  • 加入 exhaustiveness 檢查never)與 runtime 驗證,可在開發與部署階段雙重保護型別正確性。
  • API 回傳、狀態管理、圖形實體 等多樣化情境中,discriminated union 能顯著提升程式碼的可讀性與維護性。

掌握這項技巧,你將能在 TypeScript 專案中寫出 更嚴謹、更易維護 的程式碼,減少執行時錯誤,提升開發效率。祝你在 TypeScript 的型別世界裡玩得開心! 🚀