🎯 什麼情境該想到我

當你的型別變成一個「選配欄位袋(bag of optionals)」——一堆 ?: 欄位,而且沒人說得清哪些欄位會同時出現的時候。

⚙️ 怎麼用(步驟 / 公式)

病徵:太鬆的型別

type State = {
  status: 'loading' | 'success' | 'error'
  error?: string
  data?: string
}

問題有兩層:

  1. 存取時型別不準——判斷完 status === 'error',state.error 依然是 string | undefined。
  2. 能造出不可能發生的值——{ status: 'loading', error: '...', data: '...' } 完全合法。

解法:把欄位綁到它真正屬於的那個狀態

type State =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: string }
  1. 選一個判別欄位(discriminant):一個字面量型別、且每個成員都不同的共同屬性(這裡是 status)。
  2. 把每個狀態拆成獨立的物件型別。
  3. 只把該狀態真正需要的資料放進去。

現在判斷完 status === 'error',TS 的**收窄(narrowing)**會讓 state.error 直接是 string,不用再檢查 undefined。
成員多了可以各自抽成具名 type alias 再組成聯集,維持可讀性。

心法

讓不可能的狀態變成編不過的程式碼,而不是靠註解或紀律去記「loading 的時候不要看 error」。

4. ★ 窮盡性檢查:讓「漏處理」變成編譯錯誤

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "square": return s.side ** 2;
    default: {
      // ★ 加了新的 kind 卻忘了處理 → s 不再是 never → 這一行編譯錯誤
      const _exhaustive: never = s;
      throw new Error(`unhandled shape: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

★ 這是 TS 相對 Go 的一個實質優勢。
Go 的 switch 漏一個 case 只會在執行期走到 default;
TS 用 never 讓編譯器幫你檢查——加一個新的變體,所有沒處理它的地方全部編譯失敗。

實務價值:新增一種訂單狀態、一種事件類型、一種錯誤種類時,
編譯器會列出所有需要更新的地方,而不是等到線上才發現漏了一處。

更簡潔的寫法(把它抽成工具函式):

export function assertNever(x: never): never {
  throw new Error(`unexpected: ${JSON.stringify(x)}`);
}
// default: return assertNever(s);

5. ★ 讓「不合法的狀態」根本無法被表示

這是判別聯集最有價值的用法,而且它解決的是設計問題不是語法問題:

// ❌ 用可選欄位:四種欄位組合,但只有三種是合法的
type Request = {
  status: "loading" | "success" | "error";
  data?: Data;       // ★ status 是 success 時才該有
  error?: Error;     // ★ status 是 error 時才該有
};
// → 每次用 data 都要 `if (req.data)`,而且 {status:"success"} 沒有 data 也編得過
 
// ✅ 判別聯集:不合法的組合根本不存在
type Request =
  | { status: "loading" }
  | { status: "success"; data: Data }      // ★ 保證有 data
  | { status: "error"; error: Error };     // ★ 保證有 error
 
if (req.status === "success") {
  render(req.data);          // ★ 不用檢查,型別已經保證了
}

★ “Make illegal states unrepresentable.”
每個可選欄位(?)都是一次「我沒想清楚」的自白——
它讓合法狀態的數量變成 ,而其中大部分是無意義的。

6. ★ 實務上最常用的三個場景

① API 回應

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
const r = await fetchUser(id);
if (!r.ok) return handle(r.error);
use(r.value);                     // ★ 這裡 value 保證存在

★ 這是 Go 的 (T, error) 在 TS 裡的對應,而且比 try/catch 更能讓型別系統參與。

② 狀態機(見 狀態模式)
③ 事件 / Action(Redux 的 action 就是判別聯集,見 命令模式)

7. 判別欄位的選擇

做法評價
kind / type / status 的字串字面量★ 最常用,可序列化、好讀、好 debug
success: true / false 布林只有兩種時可以,多於兩種就不行
用 in 運算子判斷欄位存在⚠️ 可行但脆弱,重構時容易壞
class + instanceof不可序列化(JSON 來回會失去原型),跨網路邊界不能用

★ 要跨網路或存進資料庫 → 一定用字串字面量的判別欄位,因為它 JSON 化之後還在。

🧪 我實際套用的紀錄

  • 2026-08-28:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 判別欄位必須是字面量型別。宣告成 kind: string 就失去縮窄能力——
    用 as const 或直接在 type 裡寫字面量(見 工具-as-const與推論寬窄)。
  • ★ default 裡的 never 檢查在 strict 關閉時無效。要開 strictNullChecks(見 工具-tsconfig基準設定)。
  • 從外部來的資料不會自動符合型別。JSON.parse 回傳 any——
    ★ 邊界上一定要驗證(zod / valibot / 手寫 type guard),否則型別只是自我安慰。
  • 變體太多會很囉嗦。超過七八個變體時,考慮用「共同欄位 + 一個小的判別聯集」而不是全部攤平。
  • 不要為了「以後可能有第二種」而先做成聯集。一種就是一種。
  • Go 沒有這個。Go 的對應做法是 struct + 一個 Kind 欄位 + 註解說明「哪些欄位在哪些 Kind 下有效」——
    沒有編譯期保證。跨語言的 API 設計要注意這個落差(見 訪問者模式)。
  • 窮盡性檢查只對「聯集」有效,對 enum 或 string 沒用。

🔗 相關工具