TypeScript 進階主題與最佳實踐:Infer 的多層運用
簡介
在日常開發中,我們常會遇到「從某個型別中抽取子型別」的需求。傳統的映射型別(如 Pick<T, K>、Partial<T>)只能一次抽取單層結構,當資料結構變得更深、更複雜時,手寫一長串條件型別會讓程式碼變得難以維護。
TypeScript 4.1 之後引入的 infer 關鍵字,讓我們可以在條件型別裡「推斷」出子型別,配合遞迴或多層嵌套,就能一次解決多層抽取的問題。
本篇文章將從 基礎概念、多層推斷、實務範例、常見陷阱 逐步說明,讓你在大型專案中也能自信運用 infer,寫出可讀性高、可重用性強的型別工具。
核心概念
1. infer 基本語法
infer 必須出現在條件型別的 extends 子句中,用來宣告一個「待推斷」的型別參數。例如:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
上例中,若 T 是函式型別,infer R 會把函式的回傳型別推斷為 R,最終得到函式的回傳型別。
重點:
infer只能在條件型別的true分支裡使用,且只能推斷一次。
2. 多層推斷:從陣列到 Tuple
有時候我們需要同時推斷 參數型別、回傳型別,甚至 陣列元素型別。以下示範如何一次取得兩層資訊:
type FuncInfo<T> = T extends (...args: infer A) => infer R
? { args: A; return: R }
: never;
// 範例
type Example = (x: number, y: string) => boolean;
type Info = FuncInfo<Example>;
/*
Info 會是:
{
args: [number, string];
return: boolean;
}
*/
在 FuncInfo 中,我們同時使用了兩個 infer(A、R),分別抓取參數列表與回傳型別,形成一個更完整的描述。
3. 遞迴推斷:深層物件的鍵名集合
假設有一個深層巢狀的設定物件,我們想要取得所有 路徑鍵(如 "user.name.first")。透過遞迴條件型別與 infer,可以一次完成:
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}.${P}`
: never
: never;
type Paths<T> = T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends object
? Join<K, Paths<T[K]>>
: K
: never;
}[keyof T]
: never;
// 範例物件
type Config = {
server: {
host: string;
port: number;
};
user: {
name: {
first: string;
last: string;
};
roles: string[];
};
};
type ConfigPaths = Paths<Config>;
/*
ConfigPaths 會是聯合型別:
"server.host" | "server.port" |
"user.name.first" | "user.name.last" |
"user.roles"
*/
這裡的 Join 用來組合父層鍵與子層鍵,Paths 透過 遞迴 逐層 infer(雖然在這裡是隱式推斷),最終得到所有可能的路徑字串。
4. 多層 infer 搭配 映射型別:取得深層函式回傳型別
考慮以下情境:一個 API 客戶端的回傳型別是 函式,而該函式又回傳 另一個函式,最終返回資料。若要一次取得最終資料型別,我們可以這樣寫:
type DeepReturn<T> = T extends (...args: any[]) => infer R
? DeepReturn<R>
: T;
// 範例
type Api = () => () => () => { data: string };
type Result = DeepReturn<Api>;
/*
Result => { data: string }
*/
DeepReturn 會遞迴呼叫自身,直到遇到非函式型別為止,最後得到最底層的回傳型別。這樣的寫法在 高階函式(Higher‑Order Functions)或 Currying 場景中特別有用。
5. infer 與 條件聯合:過濾特定結構
有時我們想要從聯合型別中挑出符合某種結構的成員,例如只保留 Promise 型別的結果:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : never;
// 使用
type Mixed = string | Promise<number> | Promise<boolean>;
type Unwrapped = UnwrapPromise<Mixed>;
/*
Unwrapped => number | boolean
*/
結合多層 infer,我們甚至可以同時過濾 Promise 並取得 陣列內元素:
type DeepUnwrap<T> =
T extends Promise<infer U>
? DeepUnwrap<U>
: T extends (infer V)[]
? DeepUnwrap<V>
: T;
// 範例
type Complex = Promise<Promise<string[]>[]>;
type Final = DeepUnwrap<Complex>;
/*
Final => string
*/
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的解法 |
|---|---|---|
infer 只能在 true 分支 |
若把 infer 放在 false 分支會直接編譯錯誤。 |
確保所有 infer 都寫在 ? 後面的型別裡。 |
| 過度遞迴導致編譯速度下降 | 複雜的遞迴條件型別會讓 TypeScript 編譯器卡頓。 | 盡量限制遞迴深度,或使用 Extract、Exclude 先縮小範圍。 |
any 會吞掉推斷 |
若 infer 的目標是 any,推斷結果會變成 any,失去型別安全。 |
在條件型別前加上 unknown 轉換:T extends unknown ? ...。 |
| 鍵名不符合字串/數字 | keyof 可能產生 symbol,在拼接字串時會出錯。 |
使用 `K extends string |
| 遺失可選屬性 | 在遞迴抽取路徑時,? 會被忽略。 |
透過 Required<T> 或自行處理 undefined。 |
最佳實踐:
- 保持單一職責:每個
infer型別工具只解決一個問題(如只抽取回傳型別、只展開路徑),組合時再使用交叉或聯合。 - 加上註解:因為條件型別的可讀性較低,務必在定義前寫明用途與限制。
- 測試型別:使用
type Expect<T extends true> = T;搭配// @ts-expect-error來驗證推斷結果正確。 - 避免過度抽象:若
infer只能解決一次抽取,考慮直接寫多個簡單型別,而非一次寫成超大型遞迴。
實際應用場景
1. API 回傳型別自動推斷
在前端專案中,常會寫一個 request<T>() 函式,回傳 Promise<T>。若後端改成 NestJS 的 Observable<T>,我們仍想用同一套型別工具取得最終資料型別:
type AsyncResult<T> = T extends Promise<infer U>
? AsyncResult<U>
: T extends Observable<infer V>
? AsyncResult<V>
: T;
// 用法
declare function request<T>(url: string): Promise<T>;
type User = AsyncResult<ReturnType<typeof request>>; // 推斷最終資料型別
2. 表單驗證規則自動生成
假設有一個深層的表單結構,我們想根據每個欄位的型別自動產生 必填 或 型別檢查 的驗證規則:
type Validator<T> = {
[K in keyof T]: T[K] extends string
? { required: true; type: "string" }
: T[K] extends number
? { required: true; type: "number" }
: T[K] extends object
? Validator<T[K]>
: never;
};
type Form = {
user: {
name: string;
age: number;
};
settings: {
theme: string;
};
};
type FormValidator = Validator<Form>;
/*
FormValidator 會是:
{
user: {
name: { required: true; type: "string" };
age: { required: true; type: "number" };
};
settings: {
theme: { required: true; type: "string" };
};
}
*/
上述 Validator 內部利用 遞迴 與 infer(隱式)取得子層型別,再生成對應的驗證規則。
3. Redux Toolkit 的 Action Payload 解析
在 Redux Toolkit 中,createAsyncThunk 會回傳一個 ThunkAction,其 payloadCreator 可能是多層函式。若想取得最終 payload 型別,可使用 DeepReturn:
import { createAsyncThunk } from '@reduxjs/toolkit';
const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
const res = await fetch(`/api/user/${id}`);
return (await res.json()) as { id: string; name: string };
});
type Payload = DeepReturn<typeof fetchUser['pending']>;
// Payload => { id: string; name: string }
這樣在 reducer 裡寫 action.payload 時,IDE 能正確提示屬性名稱,提升開發效率。
總結
infer是 TypeScript 進階型別推斷的核心工具,配合條件型別與遞迴,可一次解決多層結構的抽取與轉換。- 透過 多層
infer、遞迴型別、映射型別,我們能夠自動產生函式參數、回傳型別、路徑字串、深層資料解包等實用資訊。 - 在實務開發中,
infer常見於 API 回傳型別自動推斷、表單驗證規則生成、高階函式/Redux thunk 等情境,能大幅減少手寫型別的繁瑣與錯誤。 - 使用時需注意 避免過度遞迴、正確放置
infer、以及 過濾非字串鍵,並透過 型別測試 保障正確性。
掌握了上述技巧後,你就能在大型 TypeScript 專案中,寫出既安全又彈性的型別工具,讓程式碼的可讀性與維護性同步提升。祝開發順利,玩得開心! 🚀