🎯 什麼情境該想到我
當你的型別變成一個「選配欄位袋(bag of optionals)」——一堆 ?: 欄位,而且沒人說得清哪些欄位會同時出現的時候。
⚙️ 怎麼用(步驟 / 公式)
病徵:太鬆的型別
type State = {
status: 'loading' | 'success' | 'error'
error?: string
data?: string
}問題有兩層:
- 存取時型別不準——判斷完
status === 'error',state.error依然是string | undefined。 - 能造出不可能發生的值——
{ status: 'loading', error: '...', data: '...' }完全合法。
解法:把欄位綁到它真正屬於的那個狀態
type State =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: string }- 選一個判別欄位(discriminant):一個字面量型別、且每個成員都不同的共同屬性(這裡是
status)。 - 把每個狀態拆成獨立的物件型別。
- 只把該狀態真正需要的資料放進去。
現在判斷完 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沒用。
🔗 相關工具
- 工具-as-const與推論寬窄 —— 判別欄位為什麼要是字面量
- 工具-satisfies與型別標註 —— 保留字面量型別又檢查形狀
- 工具-tsconfig基準設定 —— strict 是前提
- 工具-型別推導還是解耦 —— 型別該推導還是該明寫
- 訪問者模式 —— ★ TS 用判別聯集完勝訪問者模式
- 狀態模式 —— 讓不合法的狀態轉換無法被表示
- 命令模式 —— Redux action 就是判別聯集
- Total TypeScript Essentials