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 alias 或 interface 裡使用,不能直接在函式簽名裡寫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 進行限制。 |
最佳實踐
- 保持條件型別簡潔:將複雜的
infer邏輯拆成小工具型別,提升可讀性。 - 加入註解說明推斷意圖:尤其在多人協作的專案中,
infer的語意不易一眼看出。 - 使用
never作為失敗的安全網:在 API 設計時,明確返回never,讓使用者知道型別不匹配。 - 測試型別:利用
type測試(如type Assert<T extends true> = T;)確保推斷結果符合預期。
實際應用場景
1. 自動生成 Redux Action Creator 的型別
在大型 Redux 專案中,我們常需要為每個 Action 定義 type、payload,手寫會很繁瑣。利用 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 的型別系統裡玩出更高階的型別運算,讓程式碼既安全又表達力十足。不妨把本文的範例帶回自己的專案,逐步替換掉冗長的手寫型別,體驗型別推斷帶來的開發快感吧! 🚀