TypeScript 泛型(Generics)── 多型別參數(Multiple Type Params)
簡介
在大型前端或 Node.js 專案中,型別安全是維護程式碼品質的關鍵。TypeScript 的泛型讓我們可以在保持靈活性的同時,仍然得到編譯期的型別檢查。而 多型別參數(multiple type parameters)則是泛型的進階用法,能讓一個函式、介面或類別同時接受多個不同的型別,從而描述更複雜的資料結構與演算法。
掌握多型別參數的寫法與限制,能讓你在以下情境中受益:
- 需要在同一個資料結構中同時保存「鍵」與「值」的型別資訊(如
Map<K, V>)。 - 編寫可同時處理 多個相依型別 的通用函式(例如
zip、merge)。 - 建立 型別推斷 更精確的高階函式,減少使用
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 };
}
此例中,T 與 U 必須都是 物件型別,否則會在編譯時拋錯。
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][]
重點:
A、B兩個型別參數讓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 object、K extends keyof T)。 |
| 型別推斷失敗 | 呼叫泛型函式時未提供足夠資訊,導致返回型別變成 unknown 或 any。 |
盡量讓參數位置能提供完整推斷,或在呼叫端手動指定型別(foo<string, number>(...))。 |
過度使用交叉型別 (&) |
交叉太多屬性會產生 屬性衝突(同名屬性型別不相容)。 | 若屬性可能衝突,考慮使用 聯合型別 (` |
| 遞迴型別深度過深 | 在類別或函式中不斷產生新型別,可能觸發 TypeScript 的 型別遞迴深度限制(預設 50)。 | 限制遞迴深度或使用 as const、readonly 等方式凍結型別,避免不必要的層層包裝。 |
預設型別與 null/undefined |
預設型別若與實際傳入的 null/undefined 不匹配,會產生難以追蹤的錯誤。 |
為可能接受 null/undefined 的參數使用 聯合型別(`T |
最佳實踐小結
- 明確限制:凡是可以預測的型別範圍,都使用
extends限制。 - 保持簡潔:若多型別參數超過 3 個,檢視是否可以合併或抽象成介面。
- 利用工具類型:
Pick<T, K>、Partial<T>、Record<K, V>等能大幅減少手寫型別的負擔。 - 寫測試:泛型的型別錯誤往往在編譯階段才顯現,配合 type‑tests(如
tsd)可提前捕捉問題。 - 文件化:在公開 API(函式、類別)上加上 JSDoc,說明每個型別參數的意圖與限制,提升可讀性。
實際應用場景
1. 前端狀態管理(Redux / Zustand)
在寫 action creator 時,常需要同時描述 payload 與 metadata 的型別:
function createAction<P, M>(type: string, payload: P, meta: M) {
return { type, payload, meta };
}
// 使用
const loginAction = createAction('login', { user: 'alice' }, { timestamp: Date.now() });
多型別參數讓 payload 與 meta 的型別分別保持獨立,避免把兩者混為一體。
2. API 客戶端生成器
自動產生 REST 或 GraphQL 客戶端時,需要根據不同端點的 請求參數 與 回應結構 產生對應型別:
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. 企業級微服務通訊
在 Kafka、RabbitMQ 等訊息佇列中,訊息的 標頭(header)與 主體(payload)型別分離,使用多型別參數可一次定義完整訊息結構,減少錯誤:
interface Message<H, P> {
headers: H;
payload: P;
}
總結
- 多型別參數是 TypeScript 泛型的核心延伸,讓函式、介面、類別能同時描述多個相依或獨立的型別。
- 透過
extends限制、交叉型別、聯合型別與映射型別的組合,我們可以寫出 型別安全且彈性十足 的通用程式碼。 - 實務上,從 資料結構、API 客戶端、狀態管理 到 驗證庫,多型別參數皆是提升可維護性與開發效率的關鍵工具。
- 注意常見陷阱(型別推斷失敗、過度交叉、遞迴深度)並遵守最佳實踐(明確限制、適度抽象、充分文件化),即可在大型專案中安全地運用這項技術。
掌握了多型別參數,你就能在 TypeScript 的型別系統裡,像拼圖一樣把不同的型別「拼」在一起,寫出既強型別又易維護的程式碼。祝你在未來的開發旅程中,玩得開心、寫得安心!