TypeScript 進階型別操作:Conditional Types (T extends U ? X : Y)
簡介
在日常的前端開發中,TypeScript 已經成為提升程式碼可讀性與安全性的必備工具。當我們的資料結構變得更複雜、函式需要根據不同的參數型別返回不同結果時,條件型別(Conditional Types)就會派上用場。
條件型別的語法 T extends U ? X : Y 像是 TypeScript 版的三元運算子,它允許我們在型別層級上根據「是否符合」某個條件來選擇型別。掌握這個概念後,我們可以寫出更具彈性、可重用的型別工具,減少手寫的重複程式碼,同時保持嚴謹的型別檢查。
本篇文章將從概念說明、實作範例、常見陷阱到實務應用,逐步帶領你了解條件型別的威力,讓你在 TypeScript 專案中得心應手。
核心概念
1. 條件型別的基本語法
type Conditional<T> = T extends string ? "是字串" : "不是字串";
T為泛型參數,代表任意型別。extends在這裡不是繼承,而是型別相容性檢查。- 若
T能夠賦值給string(即T為string或其子型別),則結果型別為"是字串";否則為"不是字串"。
小技巧:條件型別會自動分配(distribute)在聯合型別上(見下節)。
2. 分配式條件型別(Distributive Conditional Types)
當條件型別的左側是聯合型別時,TypeScript 會把每個成員分別套用條件,再把結果合併成新的聯合型別:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>; // Result 為 string[] | number[]
這個特性讓我們可以輕鬆地對每個成員做映射,類似於 Array.map 在型別層級的實作。
3. infer 關鍵字:在條件型別中抽取子型別
infer 允許我們在條件型別的「真」分支中推斷出型別變數:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (a: number, b: string) => boolean;
type FnReturn = ReturnType<Fn>; // boolean
infer R 會把符合條件的函式回傳型別捕獲到 R,在「偽」分支則回傳 never。
4. 多層條件:巢狀與交叉使用
條件型別可以互相嵌套,形成更細緻的判斷邏輯:
type DeepPartial<T> =
T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
DeepPartial 會把任意深度的物件屬性全部變為可選,對於深層資料結構的更新非常有用。
程式碼範例
以下提供 5 個實用範例,說明條件型別在不同情境下的應用。每段程式碼皆附有說明註解,方便快速理解。
範例 1:根據型別返回不同字串
type Describe<T> = T extends string
? "這是一個字串"
: T extends number
? "這是一個數字"
: "其他型別";
type A = Describe<string>; // "這是一個字串"
type B = Describe<42>; // "這是一個數字"
type C = Describe<boolean>; // "其他型別"
說明:透過多層條件,我們可以在型別層級完成類似
switch的判斷。
範例 2:從陣列型別抽取元素型別(自訂 ElementOf)
type ElementOf<T> = T extends (infer E)[] ? E : never;
type NumArr = number[];
type Num = ElementOf<NumArr>; // number
type Tuple = [string, boolean];
type First = ElementOf<Tuple>; // string | boolean (因為 Tuple 會被視為陣列聯合)
技巧:
infer E讓我們直接取得陣列的元素型別,若不是陣列則回傳never。
範例 3:深層 Partial(DeepPartial)
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// 使用範例
interface User {
id: number;
profile: {
name: string;
address: {
city: string;
zip: number;
};
};
}
type UpdateUser = DeepPartial<User>;
/*
type UpdateUser = {
id?: number;
profile?: {
name?: string;
address?: {
city?: string;
zip?: number;
};
};
}
*/
應用:在編寫 API 更新(PATCH)時,常需要只提供部分欄位,
DeepPartial能自動把所有巢狀屬性轉為可選。
範例 4:條件型別結合 keyof 產生 只讀 或 可寫 版本
type ReadonlyIf<T, Condition> = Condition extends true
? Readonly<T>
: T;
// 範例
interface Config {
url: string;
timeout: number;
}
type ReadonlyConfig = ReadonlyIf<Config, true>; // 所有屬性皆為 readonly
type MutableConfig = ReadonlyIf<Config, false>; // 與原本相同
說明:只要把條件抽象為布林型別,就可以在同一個工具型別裡同時支援兩種行為。
範例 5:從 Promise 型別取得解析值(Awaited 的簡易實作)
type MyAwaited<T> = T extends Promise<infer R> ? MyAwaited<R> : T;
// 測試
type P1 = Promise<string>;
type R1 = MyAwaited<P1>; // string
type P2 = Promise<Promise<number>>;
type R2 = MyAwaited<P2>; // number
重點:使用遞迴條件型別,我們能一次把多層嵌套的
Promise解析到最終值,這正是 TypeScript 內建Awaited的核心概念。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 條件型別不分配 | 若左側不是純粹的聯合型別(例如 `T | null`),分配行為可能不如預期。 |
any 會吞掉推斷 |
當 T 為 any 時,infer 會直接得到 any,導致失去型別資訊。 |
盡量避免在公共 API 中接受 any,或使用 unknown 取代。 |
| 過度遞迴導致編譯緩慢 | 遞迴條件型別若沒有適當的終止條件,會讓編譯器陷入無限遞迴。 | 確保遞迴路徑最終會走到「非 object」或「never」分支。 |
never 的意外傳播 |
在聯合型別中使用 never 會被自動剔除,可能導致型別變得過於寬鬆。 |
明確在偽分支返回 never 前,先檢查是否真的需要排除該情況。 |
infer 只能在條件的真分支使用 |
infer 只能在 ? 左側的「真」分支中出現。 |
若需要在偽分支取得型別,需改寫條件或使用輔助型別。 |
最佳實踐
- 保持簡潔:條件型別的表達式盡量保持在單行或易於閱讀的多行,避免過度巢狀。
- 使用
unknown替代any:在需要型別推斷時,unknown能提供更安全的提示。 - 加上說明註解:尤其是使用
infer時,寫下「推斷的型別是什麼」的註解,提升可維護性。 - 單元測試型別:利用
tsd或type-tests確認條件型別在不同輸入下的結果符合預期。 - 避免過深遞迴:若需要支援無限層次的結構,考慮使用
any/unknown作為遞迴上限,或在文件中說明深度限制。
實際應用場景
| 場景 | 為什麼需要條件型別 | 範例 |
|---|---|---|
| 表單驗證 | 根據欄位型別自動推導驗證規則 | type Validator<T> = T extends string ? StringValidator : NumberValidator; |
| API 回傳型別 | 根據請求參數的不同返回不同資料結構 | type Response<T> = T extends "list" ? ListResult : DetailResult; |
| Redux Action Creators | 依照 payload 型別自動產生 type 字串 |
type Action<T> = { type: T extends number ? "NUM_ACTION" : "STR_ACTION"; payload: T; } |
| 第三方函式庫的型別映射 | 把外部 JSON schema 轉成 TypeScript 型別 | type FromSchema<S> = S extends { type: "string" } ? string : S extends { type: "array", items: infer I } ? FromSchema<I>[] : never; |
| 函式重載的型別替代 | 用條件型別寫出單一泛型函式,取代繁雜的 overload 列表 | type Overloaded<T> = T extends (a: infer A) => infer R ? (arg: A) => R : never; |
在上述每個情境中,條件型別讓我們以型別程式碼取代大量的手寫檢查與重複型別宣告,提升開發效率與程式碼一致性。
總結
條件型別 (T extends U ? X : Y) 是 TypeScript 進階型別系統的核心工具之一。透過 型別相容性檢查、分配式特性、以及 infer 推斷,我們可以在編譯階段完成許多動態判斷與映射工作。
- 基本語法簡潔卻威力強大,適用於 型別分支、型別映射、遞迴深層結構等多種需求。
- 實務上,它常被用於 API 型別抽象、表單驗證、Redux/State 管理、以及 第三方套件的型別適配。
- 注意分配式行為、
any/unknown的差異,以及遞迴深度,才能避免常見的陷阱。
掌握條件型別後,你將能寫出更具彈性、可維護的型別工具,讓 TypeScript 在大型專案中發揮最大的安全保障與開發效能。快把本文的範例搬到自己的程式碼庫裡,體驗型別運算的魔法吧!