本文 AI 產出,尚未審核

TypeScript 進階主題與最佳實踐 — 進階 Conditional Type 巢狀條件


簡介

在日常的 TypeScript 開發中,條件型別(Conditional Types) 已經是提升型別安全與可讀性的利器。隨著專案規模擴大,我們常會遇到需要根據多層次的型別資訊做判斷的情境,此時 巢狀條件(Nested Conditional Types) 就顯得格外重要。透過巢狀條件,我們能在單一型別表達式裡完成多階段的型別推斷,從而減少冗餘的型別宣告、避免手動維護大量的 overload,並讓程式碼在編譯期即捕捉更多錯誤。

本篇文章將以 淺顯易懂 的方式說明什麼是巢狀條件、如何正確寫出可維護的型別,並提供實務範例、常見陷阱與最佳實踐,幫助你在日常開發中即時運用這項進階技巧。


核心概念

1. 基本 Conditional Type 回顧

type IsString<T> = T extends string ? true : false;

IsString<string>true
IsString<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 取得子型別,最終回傳 truefalse


範例 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 作為遞迴終止點。
- 盡量把遞迴限制在 小範圍(例如只在 ArrayTuple 內)。
條件型別分配性意外觸發 T extends U ? X : Y 會在 T 為聯合型別時自動分配,導致預期外的結果。 - 在需要「整體」判斷時,將 T 包在 [](如 [T] extends [U] ? X : Y)。
infer 抽取失敗返回 never infer 的模式不匹配,結果會是 never,可能傳遞錯誤資訊。 - 為 infer 加上備援分支,例如 T extends (infer U)[] ? U : neverT extends any ? never : ...
過度抽象導致型別難以閱讀 巢狀條件寫得太複雜,開發者不易理解。 - 拆分 成小型輔助型別(如 IsArray<T>ElementOf<T>),再在主型別裡組合。
- 使用 註解 逐行說明每層條件的意圖。
函式型別被誤當作物件處理 未排除 Function,會把函式屬性加上 readonly,破壞呼叫簽名。 - 在 DeepReadonlyDeepPartial 等型別裡,先 extends Function ? T : ...

最佳實踐總結

  1. 先拆後組:把複雜條件拆成可重用的子型別。
  2. 使用 never 作為安全出口,避免遞迴無止境。
  3. 明確排除函式與特殊內建型別DateRegExp…),確保型別行為符合預期。
  4. 加上詳細註解,讓團隊成員快速掌握每層條件的目的。
  5. 測試型別:利用 type 測試或 // $ExpectType 註解(如 tsd)驗證推論結果。

實際應用場景

場景 為何需要巢狀條件 範例簡述
表單驗證規則自動推導 欄位型別可能是 `string number
GraphQL / REST API 型別映射 回傳的 status 代碼決定資料結構,使用巢狀條件一次寫出所有分支。 ApiResponse<Status> 如上例 4,讓前端在 await fetch… 時即取得正確的型別提示。
多層資料結構的深層只讀或深層可選 大型設定檔或 Redux store 常需要 DeepReadonlyDeepPartial,防止誤修改。 DeepReadonly<StoreState>DeepPartial<Config>
條件型別的型別守衛(type guard)生成 依據傳入的型別自動產生對應的 isX 函式。 type Guard<T> = T extends string ? (v: any) => v is string : ...
自動產生測試資料(Mock) 根據型別結構產生隨機測試資料,需遞迴判斷每層屬性。 Mock<T> 使用巢狀條件遞迴產生 stringnumberArray<Mock<Elem>> 等。

總結

進階的 Conditional Type 巢狀條件 是 TypeScript 型別系統中最具威力的技巧之一。透過 infer、字串模板、分配性與遞迴,我們可以在編譯期完成多層次、複雜的型別推論,從而:

  • 減少冗餘程式碼(自動產生深層只讀、Partial、Validator 等型別)。
  • 提升開發體驗(即時型別提示、提前捕捉錯誤)。
  • 加速測試與維護(型別即文件,減少手寫說明)。

在實務開發中,請遵循 拆分、註解、加上安全出口 的最佳實踐,並適度使用 測試型別 來確保推論正確。掌握了巢狀條件,你就能在大型專案中以更簡潔、可讀且安全的方式描述資料結構,讓 TypeScript 成為真正的「型別守護神」。祝你寫程式寫得開心、型別寫得無懈可擊!