本文 AI 產出,尚未審核

TypeScript 泛型(Generics)── 多型別參數(Multiple Type Params)

簡介

在大型前端或 Node.js 專案中,型別安全是維護程式碼品質的關鍵。TypeScript 的泛型讓我們可以在保持靈活性的同時,仍然得到編譯期的型別檢查。而 多型別參數(multiple type parameters)則是泛型的進階用法,能讓一個函式、介面或類別同時接受多個不同的型別,從而描述更複雜的資料結構與演算法。

掌握多型別參數的寫法與限制,能讓你在以下情境中受益:

  • 需要在同一個資料結構中同時保存「鍵」與「值」的型別資訊(如 Map<K, V>)。
  • 編寫可同時處理 多個相依型別 的通用函式(例如 zipmerge)。
  • 建立 型別推斷 更精確的高階函式,減少使用 any 或手動類型斷言的情況。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後延伸到實務應用,幫助你從新手升級為能熟練運用多型別參數的中階開發者。


核心概念

1. 為什麼需要多型別參數?

單一型別參數(<T>)只能描述一個未知的型別。當一個 API 同時需要兩個或以上互相參照的型別時,單一參數就不足以表達其關係。
例如:

function pair<T>(a: T, b: T): [T, T] { ... }

上述 pair 只能接受相同型別的兩個參數,若想要接受不同型別的兩個參數,就必須使用 多型別參數

function pair<A, B>(a: A, b: B): [A, B] { ... }

2. 基本語法

在宣告泛型時,只要在尖括號 < > 中以逗號分隔即可:

function foo<T1, T2, T3>(arg1: T1, arg2: T2, arg3: T3): [T1, T2, T3] {
  return [arg1, arg2, arg3];
}
  • 順序:參數的順序會影響型別推斷的結果,編譯器會依照呼叫時傳入的實際型別自動對應。
  • 預設值:可以為某些型別參數提供預設型別,避免呼叫端必須顯式指定:
function identity<T = string>(value: T): T {
  return value;
}

3. 受限的型別參數(Constraint)

多型別參數同樣可以加入 extends 限制,以保證傳入的型別具備特定屬性或方法:

function mergeObjects<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

此例中,TU 必須都是 物件型別,否則會在編譯時拋錯。

4. 交叉型別與聯合型別的結合

多型別參數常與 交叉型別 (&)聯合型別 (|) 結合,產生更彈性的返回型別:

function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach(k => {
    result[k] = obj[k];
  });
  return result;
}
  • K extends keyof T 確保 keys 必須是 obj 的屬性名稱。
  • 返回型別 Pick<T, K> 是交叉型別的快捷寫法,僅保留選中的屬性。

程式碼範例

以下提供 5 個實用範例,從簡單到較高階,說明多型別參數的寫法與應用。

範例 1:簡易 zip 函式(將兩個陣列合併成元組陣列)

/**
 * zip 兩個相同長度的陣列,返回每一對元素組成的元組。
 * 若長度不同,只會取較短的那段。
 */
function zip<A, B>(arr1: A[], arr2: B[]): [A, B][] {
  const length = Math.min(arr1.length, arr2.length);
  const result: [A, B][] = [];
  for (let i = 0; i < length; i++) {
    result.push([arr1[i], arr2[i]]);
  }
  return result;
}

// 使用範例
const numbers = [1, 2, 3];
const letters = ['a', 'b', 'c', 'd'];
const zipped = zip(numbers, letters); // 型別為 [number, string][]

重點AB 兩個型別參數讓 zip 能同時接受不同型別的陣列,且返回的每個元組保留原始型別資訊。


範例 2:mergeObjects —— 合併兩個物件並保留所有屬性

/**
 * 合併兩個物件,返回交叉型別 T & U。
 * 兩個參數皆必須是物件,否則編譯錯誤。
 */
function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

// 使用範例
const user = { id: 1, name: 'Alice' };
const meta = { createdAt: new Date(), isAdmin: false };
const merged = mergeObjects(user, meta);
// merged 的型別為 { id: number; name: string } & { createdAt: Date; isAdmin: boolean }

技巧:若希望在返回值上保留 readonly 或可選屬性,可使用 Partial<T>Readonly<T> 等工具類型與多型別參數結合。


範例 3:groupBy —— 依屬性分組

/**
 * 依指定鍵值將陣列分組。
 * K 必須是 T 的鍵名,返回值型別為 Record<K, T[]>
 */
function groupBy<T, K extends keyof T>(items: T[], key: K): Record<string, T[]> {
  return items.reduce((acc, item) => {
    const groupKey = String(item[key]); // 轉成字串作為 Record 的鍵
    if (!acc[groupKey]) acc[groupKey] = [];
    acc[groupKey].push(item);
    return acc;
  }, {} as Record<string, T[]>);
}

// 使用範例
type Product = { id: number; category: string; price: number };
const products: Product[] = [
  { id: 1, category: 'book', price: 120 },
  { id: 2, category: 'electronics', price: 8500 },
  { id: 3, category: 'book', price: 80 },
];
const byCategory = groupBy(products, 'category');
// byCategory 的型別為 Record<string, Product[]>

說明K extends keyof T 確保 key 必須是 Product 內的屬性名稱,避免傳入不存在的鍵。


範例 4:compose —— 多函式組合(函式式編程)

/**
 * 兩個函式的組合,返回一個新函式。
 * F 和 G 均為函式型別,且 G 的返回型別必須能作為 F 的參數型別。
 */
function compose<A, B, C>(f: (b: B) => C, g: (a: A) => B): (a: A) => C {
  return (a: A) => f(g(a));
}

// 使用範例
const toNumber = (s: string) => Number(s);
const double = (n: number) => n * 2;
const doubleStringNumber = compose(double, toNumber);
const result = doubleStringNumber('21'); // 42,型別為 number

關鍵:多型別參數讓 compose 能正確推斷「前函式」與「後函式」之間的型別關係,避免在使用時手動斷言。


範例 5:類別的多型別參數——TupleBuilder

/**
 * TupleBuilder 允許逐步「push」不同型別的值,最終取得完整的元組型別。
 * T 代表已累積的元組型別,U 代表即將加入的型別。
 */
class TupleBuilder<T extends any[]> {
  private items: T;

  constructor(...items: T) {
    this.items = items;
  }

  /** 推入新元素,回傳新型別的 TupleBuilder */
  push<U>(item: U): TupleBuilder<[...T, U]> {
    return new TupleBuilder(...(this.items as any), item);
  }

  /** 取得最終的元組 */
  build(): T {
    return this.items;
  }
}

// 使用範例
const builder = new TupleBuilder()
  .push(10)          // TupleBuilder<[number]>
  .push('hello')     // TupleBuilder<[number, string]>
  .push(true);       // TupleBuilder<[number, string, boolean]>

const tuple = builder.build(); // 型別為 [number, string, boolean]

亮點:此範例展示了 型別推斷的遞迴(recursive inference),每一次 push 都會產生新的型別參數 U,並以展開運算子 (...) 組合成新的元組型別。


常見陷阱與最佳實踐

陷阱 可能的問題 解決方法 / 最佳實踐
忘記加 extends 限制 允許任意型別傳入,導致屬性存取時出現 any 或編譯錯誤。 為每個型別參數加上適當的約束(如 T extends objectK extends keyof T)。
型別推斷失敗 呼叫泛型函式時未提供足夠資訊,導致返回型別變成 unknownany 盡量讓參數位置能提供完整推斷,或在呼叫端手動指定型別(foo<string, number>(...))。
過度使用交叉型別 (&) 交叉太多屬性會產生 屬性衝突(同名屬性型別不相容)。 若屬性可能衝突,考慮使用 聯合型別 (`
遞迴型別深度過深 在類別或函式中不斷產生新型別,可能觸發 TypeScript 的 型別遞迴深度限制(預設 50)。 限制遞迴深度或使用 as constreadonly 等方式凍結型別,避免不必要的層層包裝。
預設型別與 null/undefined 預設型別若與實際傳入的 null/undefined 不匹配,會產生難以追蹤的錯誤。 為可能接受 null/undefined 的參數使用 聯合型別(`T

最佳實踐小結

  1. 明確限制:凡是可以預測的型別範圍,都使用 extends 限制。
  2. 保持簡潔:若多型別參數超過 3 個,檢視是否可以合併或抽象成介面。
  3. 利用工具類型Pick<T, K>Partial<T>Record<K, V> 等能大幅減少手寫型別的負擔。
  4. 寫測試:泛型的型別錯誤往往在編譯階段才顯現,配合 type‑tests(如 tsd)可提前捕捉問題。
  5. 文件化:在公開 API(函式、類別)上加上 JSDoc,說明每個型別參數的意圖與限制,提升可讀性。

實際應用場景

1. 前端狀態管理(Redux / Zustand)

在寫 action creator 時,常需要同時描述 payloadmetadata 的型別:

function createAction<P, M>(type: string, payload: P, meta: M) {
  return { type, payload, meta };
}

// 使用
const loginAction = createAction('login', { user: 'alice' }, { timestamp: Date.now() });

多型別參數讓 payloadmeta 的型別分別保持獨立,避免把兩者混為一體。

2. API 客戶端生成器

自動產生 RESTGraphQL 客戶端時,需要根據不同端點的 請求參數回應結構 產生對應型別:

function request<Req, Res>(url: string, data: Req): Promise<Res> {
  return fetch(url, { method: 'POST', body: JSON.stringify(data) })
    .then(r => r.json() as Promise<Res>);
}

// 呼叫
interface CreateUserReq { name: string; email: string; }
interface CreateUserRes { id: number; createdAt: string; }

request<CreateUserReq, CreateUserRes>('/api/user', { name: 'Bob', email: 'bob@mail.com' })
  .then(res => console.log(res.id));

3. 表單驗證庫

驗證規則往往需要 資料型別錯誤訊息型別 同時存在:

type Validator<T, E> = (value: T) => E | null;

function combineValidators<T, E1, E2>(
  v1: Validator<T, E1>,
  v2: Validator<T, E2>
): Validator<T, E1 | E2> {
  return (value) => v1(value) ?? v2(value);
}

4. 資料結構與演算法

實作 二元搜尋樹鏈結串列 時,節點的 常是不同型別:

class TreeNode<K, V> {
  constructor(
    public key: K,
    public value: V,
    public left: TreeNode<K, V> | null = null,
    public right: TreeNode<K, V> | null = null
  ) {}
}

5. 企業級微服務通訊

KafkaRabbitMQ 等訊息佇列中,訊息的 標頭(header)與 主體(payload)型別分離,使用多型別參數可一次定義完整訊息結構,減少錯誤:

interface Message<H, P> {
  headers: H;
  payload: P;
}

總結

  • 多型別參數是 TypeScript 泛型的核心延伸,讓函式、介面、類別能同時描述多個相依或獨立的型別。
  • 透過 extends 限制、交叉型別、聯合型別與映射型別的組合,我們可以寫出 型別安全且彈性十足 的通用程式碼。
  • 實務上,從 資料結構API 客戶端狀態管理驗證庫,多型別參數皆是提升可維護性與開發效率的關鍵工具。
  • 注意常見陷阱(型別推斷失敗、過度交叉、遞迴深度)並遵守最佳實踐(明確限制、適度抽象、充分文件化),即可在大型專案中安全地運用這項技術。

掌握了多型別參數,你就能在 TypeScript 的型別系統裡,像拼圖一樣把不同的型別「拼」在一起,寫出既強型別易維護的程式碼。祝你在未來的開發旅程中,玩得開心、寫得安心!