本文 AI 產出,尚未審核

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 中,我們同時使用了兩個 inferAR),分別抓取參數列表與回傳型別,形成一個更完整的描述。


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 編譯器卡頓。 盡量限制遞迴深度,或使用 ExtractExclude 先縮小範圍。
any 會吞掉推斷 infer 的目標是 any,推斷結果會變成 any,失去型別安全。 在條件型別前加上 unknown 轉換:T extends unknown ? ...
鍵名不符合字串/數字 keyof 可能產生 symbol,在拼接字串時會出錯。 使用 `K extends string
遺失可選屬性 在遞迴抽取路徑時,? 會被忽略。 透過 Required<T> 或自行處理 undefined

最佳實踐

  1. 保持單一職責:每個 infer 型別工具只解決一個問題(如只抽取回傳型別、只展開路徑),組合時再使用交叉或聯合。
  2. 加上註解:因為條件型別的可讀性較低,務必在定義前寫明用途與限制。
  3. 測試型別:使用 type Expect<T extends true> = T; 搭配 // @ts-expect-error 來驗證推斷結果正確。
  4. 避免過度抽象:若 infer 只能解決一次抽取,考慮直接寫多個簡單型別,而非一次寫成超大型遞迴。

實際應用場景

1. API 回傳型別自動推斷

在前端專案中,常會寫一個 request<T>() 函式,回傳 Promise<T>。若後端改成 NestJSObservable<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 專案中,寫出既安全又彈性的型別工具,讓程式碼的可讀性與維護性同步提升。祝開發順利,玩得開心! 🚀