本文 AI 產出,尚未審核

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(即 Tstring 或其子型別),則結果型別為 "是字串";否則為 "不是字串"

小技巧:條件型別會自動分配(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 會吞掉推斷 Tany 時,infer 會直接得到 any,導致失去型別資訊。 盡量避免在公共 API 中接受 any,或使用 unknown 取代。
過度遞迴導致編譯緩慢 遞迴條件型別若沒有適當的終止條件,會讓編譯器陷入無限遞迴。 確保遞迴路徑最終會走到「非 object」或「never」分支。
never 的意外傳播 在聯合型別中使用 never 會被自動剔除,可能導致型別變得過於寬鬆。 明確在偽分支返回 never 前,先檢查是否真的需要排除該情況。
infer 只能在條件的真分支使用 infer 只能在 ? 左側的「真」分支中出現。 若需要在偽分支取得型別,需改寫條件或使用輔助型別。

最佳實踐

  1. 保持簡潔:條件型別的表達式盡量保持在單行或易於閱讀的多行,避免過度巢狀。
  2. 使用 unknown 替代 any:在需要型別推斷時,unknown 能提供更安全的提示。
  3. 加上說明註解:尤其是使用 infer 時,寫下「推斷的型別是什麼」的註解,提升可維護性。
  4. 單元測試型別:利用 tsdtype-tests 確認條件型別在不同輸入下的結果符合預期。
  5. 避免過深遞迴:若需要支援無限層次的結構,考慮使用 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 在大型專案中發揮最大的安全保障與開發效能。快把本文的範例搬到自己的程式碼庫裡,體驗型別運算的魔法吧!