TypeScript 進階主題與最佳實踐 — 進階 Conditional Type 巢狀條件
簡介
在日常的 TypeScript 開發中,條件型別(Conditional Types) 已經是提升型別安全與可讀性的利器。隨著專案規模擴大,我們常會遇到需要根據多層次的型別資訊做判斷的情境,此時 巢狀條件(Nested Conditional Types) 就顯得格外重要。透過巢狀條件,我們能在單一型別表達式裡完成多階段的型別推斷,從而減少冗餘的型別宣告、避免手動維護大量的 overload,並讓程式碼在編譯期即捕捉更多錯誤。
本篇文章將以 淺顯易懂 的方式說明什麼是巢狀條件、如何正確寫出可維護的型別,並提供實務範例、常見陷阱與最佳實踐,幫助你在日常開發中即時運用這項進階技巧。
核心概念
1. 基本 Conditional Type 回顧
type IsString<T> = T extends string ? true : false;
IsString<string> → trueIsString<number> → false
條件型別的語法為 T extends U ? X : Y,當 T 能被指派給 U 時,結果為 X,否則為 Y。
2. 為什麼需要巢狀條件
單一條件只能處理 二分法 的判斷。實務上,我們常需要 多分支(例如 string | number | boolean)或 階層式 的推論(先判斷是否為陣列,再判斷元素型別)。此時我們可以將多個條件 串接 起來,形成巢狀結構:
type DeepCheck<T> =
T extends any[] // 1️⃣ 判斷是否為陣列
? T extends (infer U)[] // 2️⃣ 取得元素型別
? DeepCheck<U> // 3️⃣ 再遞迴檢查元素型別
: never
: T extends string // 4️⃣ 若不是陣列,判斷是否為字串
? '文字型別'
: T extends number
? '數字型別'
: '其他型別';
DeepCheck<string[]> → '文字型別'DeepCheck<(number | boolean)[]> → '其他型別'(因為 boolean 不在最後分支)
3. 巢狀條件的寫法技巧
| 技巧 | 說明 | 範例 |
|---|---|---|
使用 infer 抽取子型別 |
在條件裡直接推導出子型別,避免額外的型別別名。 | T extends (infer U)[] ? U : never |
| 利用分配性 (Distributive) 讓聯合類型逐一套用 | 只要條件型別的左側是裸露的型別參數(未包在 []、{} 中),聯合會自動分配。 |
type ToPromise<T> = T extends any ? Promise<T> : never |
加上 never 退出遞迴 |
防止遞迴無止境,當無法再符合條件時直接回傳 never。 |
T extends unknown ? ... : never |
使用 extends unknown 作為遞迴終止點 |
unknown 是所有型別的超型別,可作為安全的遞迴基礎。 |
type Flatten<T> = T extends unknown ? ... : never |
4. 實用範例
範例 1️⃣ 取得物件深層屬性的型別
type DeepProp<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}` // 把路徑切成第一段與剩餘段
? Key extends keyof T
? DeepProp<T[Key], Rest> // 針對子屬性遞迴
: never
: Path extends keyof T
? T[Path] // 最後一段直接取型別
: never;
// 測試
type User = {
id: number;
profile: {
name: string;
address: {
city: string;
zip: number;
};
};
};
type City = DeepProp<User, 'profile.address.city'>; // => string
type Zip = DeepProp<User, 'profile.address.zip'>; // => number
type NotExist = DeepProp<User, 'profile.age'>; // => never
重點:利用字串模板字面量 (
${infer}) 以及keyof,一次完成多層屬性的型別提取。
範例 2️⃣ 判斷是否為「可序列化」型別(支援 JSON.stringify)
type Primitive = string | number | boolean | null;
type Serializable<T> =
T extends Primitive ? true :
T extends any[] ? SerializableArray<T> :
T extends object ? SerializableObject<T> :
false;
type SerializableArray<T extends any[]> =
T extends (infer U)[] ? Serializable<U> : false;
type SerializableObject<T extends object> =
{ [K in keyof T]: Serializable<T[K]> } extends infer O
? O extends Record<string, true> ? true : false
: false;
// 測試
type Test1 = Serializable<string>; // true
type Test2 = Serializable<Date>; // false (Date 不是純粹的 object)
type Test3 = Serializable<{a: number; b: string[]}>; // true
type Test4 = Serializable<{a: () => void}>; // false
說明:此範例展示 三層 巢狀條件(基礎型別 → 陣列 → 物件),且每層都使用
infer取得子型別,最終回傳true或false。
範例 3️⃣ 自動產生「深層只讀」型別(DeepReadonly)
type DeepReadonly<T> =
T extends Function ? T :
T extends Array<infer U> ? ReadonlyArray<DeepReadonly<U>> :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
// 測試
type Nested = {
name: string;
meta: {
tags: string[];
created: Date;
};
};
type ReadonlyNested = DeepReadonly<Nested>;
/*
type ReadonlyNested = {
readonly name: string;
readonly meta: {
readonly tags: readonly string[];
readonly created: Date;
};
}
*/
技巧:透過 條件分配,
Array<infer U>只會匹配陣列型別;Function必須先排除,否則會把函式也視為物件而被包上readonly。
範例 4️⃣ 依據 API 回傳的狀態碼自動推斷回應型別
type ApiResponse<Code extends number> =
Code extends 200 ? { status: 200; data: any } :
Code extends 201 ? { status: 201; createdId: string } :
Code extends 400 ? { status: 400; error: string } :
Code extends 500 ? { status: 500; retryAfter: number } :
{ status: Code; unknown: true };
// 使用
type Res200 = ApiResponse<200>; // { status: 200; data: any }
type Res400 = ApiResponse<400>; // { status: 400; error: string }
type Res999 = ApiResponse<999>; // { status: 999; unknown: true }
重點:每一層條件都是 獨立的分支,形成 巢狀的選擇樹,讓開發者在呼叫 API 時即能得到正確的回傳型別提示。
常見陷阱與最佳實踐
| 陷阱 | 可能的問題 | 解決方式 |
|---|---|---|
| 遞迴過深導致編譯速度變慢 | 多層巢狀條件或遞迴型別會讓 TypeScript 編譯器卡頓。 | - 使用 never 作為遞迴終止點。- 盡量把遞迴限制在 小範圍(例如只在 Array、Tuple 內)。 |
| 條件型別分配性意外觸發 | T extends U ? X : Y 會在 T 為聯合型別時自動分配,導致預期外的結果。 |
- 在需要「整體」判斷時,將 T 包在 [](如 [T] extends [U] ? X : Y)。 |
infer 抽取失敗返回 never |
若 infer 的模式不匹配,結果會是 never,可能傳遞錯誤資訊。 |
- 為 infer 加上備援分支,例如 T extends (infer U)[] ? U : never → T extends any ? never : ... |
| 過度抽象導致型別難以閱讀 | 巢狀條件寫得太複雜,開發者不易理解。 | - 拆分 成小型輔助型別(如 IsArray<T>、ElementOf<T>),再在主型別裡組合。- 使用 註解 逐行說明每層條件的意圖。 |
| 函式型別被誤當作物件處理 | 未排除 Function,會把函式屬性加上 readonly,破壞呼叫簽名。 |
- 在 DeepReadonly、DeepPartial 等型別裡,先 extends Function ? T : ...。 |
最佳實踐總結
- 先拆後組:把複雜條件拆成可重用的子型別。
- 使用
never作為安全出口,避免遞迴無止境。 - 明確排除函式與特殊內建型別(
Date、RegExp…),確保型別行為符合預期。 - 加上詳細註解,讓團隊成員快速掌握每層條件的目的。
- 測試型別:利用
type測試或// $ExpectType註解(如tsd)驗證推論結果。
實際應用場景
| 場景 | 為何需要巢狀條件 | 範例簡述 |
|---|---|---|
| 表單驗證規則自動推導 | 欄位型別可能是 `string | number |
| GraphQL / REST API 型別映射 | 回傳的 status 代碼決定資料結構,使用巢狀條件一次寫出所有分支。 |
ApiResponse<Status> 如上例 4,讓前端在 await fetch… 時即取得正確的型別提示。 |
| 多層資料結構的深層只讀或深層可選 | 大型設定檔或 Redux store 常需要 DeepReadonly、DeepPartial,防止誤修改。 |
DeepReadonly<StoreState>、DeepPartial<Config>。 |
| 條件型別的型別守衛(type guard)生成 | 依據傳入的型別自動產生對應的 isX 函式。 |
type Guard<T> = T extends string ? (v: any) => v is string : ...。 |
| 自動產生測試資料(Mock) | 根據型別結構產生隨機測試資料,需遞迴判斷每層屬性。 | Mock<T> 使用巢狀條件遞迴產生 string、number、Array<Mock<Elem>> 等。 |
總結
進階的 Conditional Type 巢狀條件 是 TypeScript 型別系統中最具威力的技巧之一。透過 infer、字串模板、分配性與遞迴,我們可以在編譯期完成多層次、複雜的型別推論,從而:
- 減少冗餘程式碼(自動產生深層只讀、Partial、Validator 等型別)。
- 提升開發體驗(即時型別提示、提前捕捉錯誤)。
- 加速測試與維護(型別即文件,減少手寫說明)。
在實務開發中,請遵循 拆分、註解、加上安全出口 的最佳實踐,並適度使用 測試型別 來確保推論正確。掌握了巢狀條件,你就能在大型專案中以更簡潔、可讀且安全的方式描述資料結構,讓 TypeScript 成為真正的「型別守護神」。祝你寫程式寫得開心、型別寫得無懈可擊!