本文 AI 產出,尚未審核

TypeScript 進階型別操作 – infer 關鍵字


簡介

在大型 TypeScript 專案中,我們常會需要根據已有型別推導出新的型別,例如從函式的參數型別抽取回傳型別、或從陣列元素推斷聯合型別。傳統的條件型別(T extends U ? X : Y)只能直接比較型別,無法在匹配過程中「抓取」子型別的資訊。
自 TypeScript 2.8 起推出的 infer 關鍵字,正是為了在條件型別裡動態推斷(infer)型別而設計的。它讓型別系統能像正則表達式的捕獲群組一樣,從複雜型別結構中抽取子型別,從而寫出更抽象、更可重用的型別工具。

掌握 infer 不僅能讓你寫出更乾淨的型別程式碼,還能減少重複宣告與手動維護的成本,提升開發效率與型別安全性。以下我們將一步步拆解 infer 的原理、用法與實務案例,幫助你從「新手」升級為「型別魔法師」。


核心概念

1. infer 的語法基礎

infer 必須放在條件型別的真值分支? 之後)中,語法如下:

type MyExtract<T> = T extends infer U ? U : never;
  • T 為待檢查的型別。
  • infer U 表示如果匹配成功,就把匹配到的型別賦值給 U
  • 整個條件型別會回傳 U(或 never,取決於匹配結果)。

注意infer 只能在 條件型別的 true 分支 中使用,且只能在 type aliasinterface 裡使用,不能直接在函式簽名裡寫 infer


2. 從函式型別抽取參數與回傳型別

最常見的 infer 用例是從函式型別中抽取參數型別或回傳型別

範例 1:抽取單一參數型別

type FirstParam<T> = T extends (arg: infer P, ...any[]) => any ? P : never;

// 測試
type Fn = (x: number, y: string) => void;
type Param = FirstParam<Fn>; // => number
  • T extends (arg: infer P, ...any[]) => any:如果 T 符合函式型別,則把第一個參數的型別推斷成 P
  • T 不是函式,結果會是 never

範例 2:抽取回傳型別

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// 測試
type Fn2 = (a: string) => Promise<number>;
type Res = ReturnType<Fn2>; // => Promise<number>

這其實是 TypeScript 標準庫裡的 ReturnType<T>,展示了 infer 在官方型別工具中的核心地位。


3. 從陣列或元組抽取元素型別

infer 也能用在 陣列與元組 上,讓我們可以取得「第一個」或「最後一個」元素的型別。

範例 3:取得陣列第一個元素型別

type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;

// 測試
type Arr = [string, number, boolean];
type First = Head<Arr>; // => string

範例 4:取得元組最後一個元素型別

type Tail<T extends any[]> = T extends [...any[], infer L] ? L : never;

// 測試
type Tuple = [number, string, Date];
type Last = Tail<Tuple>; // => Date

小技巧:配合 infer 與遞迴條件型別,我們甚至可以寫出「取得陣列所有元素型別的聯合」之類的高階工具。


4. 透過遞迴與 infer 製作深層型別抽取

以下示範如何利用 infer 搭配 遞迴條件型別,把多層嵌套的 Promise 解析成最終值型別。

範例 5:解包多層 Promise (DeepAwaited)

type DeepAwaited<T> =
  T extends Promise<infer U>          // 如果是 Promise,取出內層 U
    ? DeepAwaited<U>                  // 再遞迴一次,直到不是 Promise 為止
    : T;                              // 不是 Promise,直接回傳本身

// 測試
type P1 = Promise<Promise<string>>;
type Result = DeepAwaited<P1>; // => string

這個技巧在處理 async/await第三方函式庫(如 axios)回傳的多層 Promise 時非常有用。


5. infer 與映射型別結合

infer 也能與 映射型別(Mapped Types)配合,動態產生新屬性名稱或型別。

範例 6:把所有屬性型別轉成 readonly,同時保留原型別資訊

type ToReadonly<T> = {
  [K in keyof T as K extends `_${infer Rest}` ? Rest : K]: // 把以 _ 開頭的屬性名稱去掉 _
    readonly T[K];
};

// 測試
interface User {
  _id: number;
  name: string;
  age: number;
}
type ReadonlyUser = ToReadonly<User>;
/*
type ReadonlyUser = {
  id: readonly number;
  name: readonly string;
  age: readonly number;
}
*/

此範例展示了 infer鍵名重映射(key remapping)中的威力,讓型別轉換變得更彈性。


常見陷阱與最佳實踐

陷阱 說明 解決方式
infer 只能在 true 分支 若把 infer 放在 false 分支,編譯會錯誤。 確保 infer 僅出現在 ? 後的型別表達式中。
過度遞迴導致編譯緩慢 深層遞迴(如無限制的 DeepAwaited)可能讓 TypeScript 編譯器卡住。 為遞迴加上 深度上限(利用輔助型別限制遞迴層數)或在實務中避免過度抽象。
推斷失敗返回 never 當型別不匹配時,infer 會回傳 never,若未妥善處理會造成意外錯誤。 使用 預設型別T extends ... ? ... : Default)或加上 型別保護
infer 只能推斷一次 在同一條件型別裡只能宣告一次 infer,多次推斷需要拆成多個條件或使用元組。 把複雜推斷拆成多層條件型別或使用 元組解構
any/unknown 混用時不易偵錯 any 會使推斷失去意義,unknown 則需要額外斷言。 盡量在型別層級保持 具體,必要時使用 unknown 再加 extends 進行限制。

最佳實踐

  1. 保持條件型別簡潔:將複雜的 infer 邏輯拆成小工具型別,提升可讀性。
  2. 加入註解說明推斷意圖:尤其在多人協作的專案中,infer 的語意不易一眼看出。
  3. 使用 never 作為失敗的安全網:在 API 設計時,明確返回 never,讓使用者知道型別不匹配。
  4. 測試型別:利用 type 測試(如 type Assert<T extends true> = T;)確保推斷結果符合預期。

實際應用場景

1. 自動生成 Redux Action Creator 的型別

在大型 Redux 專案中,我們常需要為每個 Action 定義 typepayload,手寫會很繁瑣。利用 infer 可以從 Action Creator 函式自動抽取 payload 型別:

type ActionCreator<T> = (...args: any[]) => { type: string; payload: T };

type PayloadOf<A> = A extends ActionCreator<infer P> ? P : never;

// 示例
const addTodo = (text: string) => ({
  type: 'ADD_TODO',
  payload: { text },
});

type AddTodoPayload = PayloadOf<typeof addTodo>; // => { text: string }

這樣即使更改 addTodo 的參數,AddTodoPayload 也會自動同步更新。


2. API 回傳型別自動解包

許多 REST/GraphQL 客戶端會返回 { data: T; error?: string } 結構。使用 infer 可以快速取得 data 部分的型別,減少手動寫 type Response<T> = { data: T }

type ApiResponse<T> = { data: T; error?: string };

type DataOf<R> = R extends ApiResponse<infer D> ? D : never;

// 使用
type UserResponse = ApiResponse<{ id: number; name: string }>;
type User = DataOf<UserResponse>; // => { id: number; name: string }

在寫 API 客戶端的泛型函式時,DataOf 讓開發者只關注核心資料型別。


3. 高階函式庫的類型推斷(例如 RxJS)

RxJS 中的 pipe 允許任意數量的 operator 組合。若想寫一個自訂的 compose,需要把每個 operator 的輸入/輸出型別串接起來。infer 能在條件型別裡分段抽取:

type Operator<I, O> = (source: I) => O;

type Compose<A> = A extends [infer First, ...infer Rest]
  ? First extends Operator<infer I, infer O>
    ? Rest extends []
      ? Operator<I, O>
      : Compose<Rest> extends Operator<O, infer R>
        ? Operator<I, R>
        : never
    : never
  : never;

// 範例 operator
const toNumber: Operator<string, number> = s => +s;
const double: Operator<number, number> = n => n * 2;

// 組合後的型別
type Combined = Compose<[typeof toNumber, typeof double]>; // Operator<string, number>

這讓自訂的 compose 能保持型別安全,避免在運行時出現型別不匹配的錯誤。


總結

  • infer 是 TypeScript 條件型別中,用於動態推斷子型別的關鍵字,類似正則表達式的捕獲群組。
  • 它可以從 函式、陣列、元組、Promise、物件鍵名 等結構中抽取資訊,讓型別工具變得更抽象且可重用。
  • 使用時須注意只能放在 true 分支、避免過度遞迴、妥善處理 never,並配合 註解、拆分工具型別 以提升可讀性。
  • Redux Action、API 回傳、函式庫組合 等實務情境中,infer 能大幅減少手動型別宣告,提升開發效率與型別安全。

掌握 infer 後,你將能在 TypeScript 的型別系統裡玩出更高階的型別運算,讓程式碼既安全表達力十足。不妨把本文的範例帶回自己的專案,逐步替換掉冗長的手寫型別,體驗型別推斷帶來的開發快感吧! 🚀