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 的步驟
| 步驟 | 說明 |
|---|---|
| ① 定義判別欄位 | 建議使用 kind、type、status 等語意清晰的名稱。值必須是 字面值("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 guard 或 as const 讓編譯器自行判斷。 |
忘記 exhaustive check |
新增子型別卻忘記更新 switch,導致隱藏錯誤。 |
在 default 中加入 never 檢查,讓編譯器提醒。 |
最佳實踐小結
- 始終使用
readonly讓判別欄位不可變。 - 把判別欄位放在最外層(或最易取得的位置),減少嵌套查找成本。
- 使用
enum或字面值常數,避免硬編碼字串導致的 typo。 - 加入 exhaustiveness 檢查(
never)以防止遺漏分支。 - 結合 runtime 驗證(例如
zod)確保從外部取得的資料符合 discriminated union。
實際應用場景
| 場景 | 為什麼適合使用 Discriminated Union |
|---|---|
| API 回傳多種結果(成功、失敗、驗證錯誤) | 判別欄位 status 或 code 讓前端直接根據型別處理 UI。 |
| Redux / NgRx 等狀態管理的 Action | 每個 Action 皆有 type(enum)與對應 payload,Reducer 可安全地做分支。 |
| 表單驗證結果 | kind: "valid" / "invalid" + 具體錯誤資訊,使 UI 渲染更簡潔。 |
| 圖形繪圖或遊戲引擎的實體 | `kind: "player" |
| 多語系訊息或日誌系統 | `type: "info" |
總結
- Discriminated Union 為 TypeScript 提供了 結構化的型別保護:只要在每個子型別中加入唯一且必定存在的判別欄位,編譯器即可在
if、switch等控制流程中自動 縮小型別,讓屬性存取變得安全、直觀。 - 使用
enum、as const或readonly可避免常見的字串 typo 與可變性問題。 - 加入 exhaustiveness 檢查(
never)與 runtime 驗證,可在開發與部署階段雙重保護型別正確性。 - 在 API 回傳、狀態管理、圖形實體 等多樣化情境中,discriminated union 能顯著提升程式碼的可讀性與維護性。
掌握這項技巧,你將能在 TypeScript 專案中寫出 更嚴謹、更易維護 的程式碼,減少執行時錯誤,提升開發效率。祝你在 TypeScript 的型別世界裡玩得開心! 🚀