TypeScript 泛型函式(Generic Function)
簡介
在日常開發中,我們常會寫出許多可重複使用的工具函式,例如陣列的 filter、物件的 clone、或是 API 回傳資料的轉換函式。若這些函式的參數或回傳值型別寫死,使用者每次呼叫時都必須自行做型別斷言(type assertion)或是重寫相似的程式碼,既違背了 TypeScript 強型別的精神,也會讓程式碼的可讀性與維護性大幅下降。
泛型函式(Generic Function)正是為了解決這個問題而設計的。它允許我們在函式宣告時先定義一個或多個「型別參數」,在實際呼叫時再由編譯器根據傳入的實參自動推算出具體的型別。這樣不僅保留了型別安全,還能讓函式在不同情境下保持 高度彈性 與 可重用性。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後延伸到真實的應用場景,帶領讀者一步一步掌握 泛型函式 的核心技巧,適合剛踏入 TypeScript 的新手,也能為已有經驗的開發者提供進階的思考方向。
核心概念
1. 基本語法:<T> 表示型別參數
在 TypeScript 中,使用尖括號 <> 包住的名稱(慣例上使用 T、U、K 等)即代表一個 型別參數。下面是一個最簡單的泛型函式範例:
function identity<T>(value: T): T {
return value;
}
T為函式的型別參數,代表「任意型別」- 參數
value的型別被標記為T,回傳值同樣是T - 呼叫時,編譯器會根據傳入的實際值自動推斷
T為什麼型別
const num = identity(42); // T 被推斷為 number,num 的型別是 number
const str = identity('hello'); // T 被推斷為 string,str 的型別是 string
重點:只要函式內部不對
value做任何不安全的操作,identity就能在所有型別上正確工作。
2. 多個型別參數:<T, U>
有時候函式的輸入與輸出型別不一定相同,這時可以同時宣告多個型別參數:
function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
const result: U[] = [];
for (const item of arr) {
result.push(fn(item));
}
return result;
}
const numbers = [1, 2, 3];
const strings = mapArray(numbers, n => `num-${n}`); // U 被推斷為 string
// strings 的型別是 string[]
T代表原始陣列的元素型別U代表映射後的元素型別
3. 預設型別參數
如果大多數情況下某個型別參數都有固定的預設值,可使用 = 指定預設型別:
function wrapInArray<T = string>(value: T): T[] {
return [value];
}
const a = wrapInArray(123); // T 推斷為 number,返回 number[]
const b = wrapInArray('abc'); // T 為 string,返回 string[]
const c = wrapInArray(); // 參數缺失會報錯,但若寫成 wrapInArray(undefined) 則會使用預設的 string
4. 限制型別參數:extends
有時候我們希望型別參數只能是某個介面的子型別,這時可以使用 extends 限制:
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength([1, 2, 3]); // OK,陣列有 length 屬性
logLength('hello'); // OK,字串有 length 屬性
// logLength(123); // 錯誤,number 沒有 length
5. 使用 keyof 取得屬性鍵集合
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: 'Alice', age: 30 };
const name = getProperty(person, 'name'); // name 的型別是 string
const age = getProperty(person, 'age'); // age 的型別是 number
// getProperty(person, 'address'); // 錯誤,'address' 不在 keyof person 之內
程式碼範例
以下提供 5 個實務上常會用到 的泛型函式範例,並附上詳細註解說明。
範例 1:深度克隆(Deep Clone)
function deepClone<T>(obj: T): T {
// 只處理可 JSON 序列化的情況,實務上可根據需求改寫
return JSON.parse(JSON.stringify(obj));
}
// 使用
interface User {
id: number;
name: string;
meta: { tags: string[] };
}
const user: User = { id: 1, name: 'Bob', meta: { tags: ['admin'] } };
const copy = deepClone(user);
// copy 與 user 為不同的記憶體參考,且型別保持一致
技巧:
deepClone回傳值型別直接使用T,確保即使是嵌套物件也能保留原本的型別資訊。
範例 2:條件過濾(filter)
function filterArray<T>(arr: T[], predicate: (item: T) => boolean): T[] {
const result: T[] = [];
for (const item of arr) {
if (predicate(item)) result.push(item);
}
return result;
}
// 範例:過濾出年齡大於 18 歲的使用者
interface Person {
name: string;
age: number;
}
const people: Person[] = [
{ name: 'Amy', age: 16 },
{ name: 'Brian', age: 22 },
];
const adults = filterArray(people, p => p.age >= 18);
// adults 的型別是 Person[]
範例 3:將物件鍵值對轉成陣列(Object.entries 的型別安全版)
function entries<T extends object>(obj: T): [keyof T, T[keyof T]][] {
return Object.entries(obj) as [keyof T, T[keyof T]][];
}
// 使用
const point = { x: 10, y: 20 };
const pointEntries = entries(point);
// pointEntries 的型別是 [ "x" | "y", number ][]
注意:
Object.entries原生回傳any[][],透過泛型與斷言,我們可以在編譯期取得正確的鍵值型別。
範例 4:函式組合(Function Composition)
function compose<A, B, C>(
f: (b: B) => C,
g: (a: A) => B
): (a: A) => C {
return (a: A) => f(g(a));
}
// 範例
const addOne = (n: number) => n + 1;
const toString = (n: number) => n.toString();
const addOneThenString = compose(toString, addOne);
const result = addOneThenString(5); // "6"
- 這裡使用了 兩個型別參數
A、B、C,讓組合後的函式在任何型別下都保持正確的輸入與輸出。
範例 5:Promise 併發控制(Promise.all 的型別安全版)
function all<T extends readonly unknown[]>(promises: T): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
return Promise.all(promises) as any;
}
// 使用
const p1 = Promise.resolve(10);
const p2 = Promise.resolve('hello');
const p3 = Promise.resolve(true);
all([p1, p2, p3]).then(([n, s, b]) => {
// n: number, s: string, b: boolean
console.log(n, s, b);
});
Awaited<T[K]>會把Promise<T>解析為實際的值型別,確保all回傳的陣列元素型別正確。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的解決方式 |
|---|---|---|
| 型別推斷失敗 | 有時候 TypeScript 無法正確推斷泛型型別,會退回 any。 |
在呼叫時顯式指定型別參數,例如 identity<number>(42)。 |
過度寬鬆的 extends any |
使用 extends any 會失去泛型的約束,讓錯誤在編譯期無法被捕捉。 |
盡量以具體的介面或型別限制,例如 T extends Lengthwise。 |
any 斷言 |
為了配合 Object.entries 等 API,常會使用 as any,這會削弱型別安全。 |
透過自訂泛型函式(如上面的 entries)或 as const 斷言來保留型別資訊。 |
| 遞迴型別過深 | 在深度遞迴的泛型(如深層樹結構)可能導致編譯器報錯 Type instantiation is excessively deep...。 |
使用 條件型別 (infer) 或 分段遞迴,或在 tsconfig.json 中調整 maxNodeModuleJsDepth。 |
忘記處理 null / undefined |
泛型函式如果接受 `T | null`,在內部使用時可能忘記檢查。 |
最佳實踐
- 保持函式單一職責:泛型函式不宜同時處理太多概念,否則型別推斷會變得模糊。
- 盡量使用
keyof、infer來取得物件的鍵值型別,提升可讀性與安全性。 - 提供明確的 JSDoc,讓使用者在 IDE 中能即時看到泛型參數的限制說明。
- 測試型別:使用
tsd或dtslint之類的工具寫型別測試,確保函式在不同型別下仍能正確編譯。
實際應用場景
1. API 回傳資料的型別映射
在前端與後端分離的專案中,後端通常回傳 JSON,但每個端點的資料結構都不同。透過泛型函式,我們可以寫一個 fetchJson<T>,在呼叫時即指定回傳型別,讓後續的程式碼自動得到正確的型別提示。
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
return (await response.json()) as T;
}
// 使用
interface Todo {
id: number;
title: string;
completed: boolean;
}
const todo = await fetchJson<Todo>('/api/todos/1');
// todo 的型別已是 Todo,IDE 會自動補全屬性
2. 表單驗證函式庫
表單欄位的值類型多樣(string、number、Date),而驗證規則往往依欄位型別而異。透過泛型,我們可以寫一個 validateField<T>,在傳入欄位值時自動推斷對應的驗證器。
type Validator<T> = (value: T) => string | null;
function validateField<T>(value: T, validator: Validator<T>): string | null {
return validator(value);
}
// 範例驗證器
const isNonEmpty: Validator<string> = v => v.trim() === '' ? '不可為空' : null;
const isPositive: Validator<number> = v => v > 0 ? null : '必須大於 0';
validateField('hello', isNonEmpty); // null
validateField(-5, isPositive); // '必須大於 0'
3. Redux / Zustand 等狀態管理的 setState
在使用 immutable 的狀態管理時,常需要寫一個 update<T> 函式,只接受部分更新的屬性,並回傳完整的狀態物件。
function updateState<S>(state: S, patch: Partial<S>): S {
return { ...state, ...patch };
}
// 使用
interface CounterState {
count: number;
step: number;
}
let counter: CounterState = { count: 0, step: 1 };
counter = updateState(counter, { count: counter.count + counter.step });
Partial<S> 本身就是一個泛型型別,配合 updateState 的設計,使得 型別安全 與 開發體驗 同時提升。
總結
- 泛型函式是 TypeScript 讓程式碼保持 型別安全 同時又具 彈性 的核心機制。
- 透過 型別參數、多型別參數、預設值、以及
extends限制,我們可以在函式層級精確描述資料流向。 - 實務上,從 深度克隆、資料過濾、物件鍵值轉換、函式組合、到 Promise 併發控制,只要把重複的邏輯抽成泛型函式,就能大幅減少程式碼冗餘、提升可讀性。
- 常見的陷阱包括 型別推斷失敗、過度寬鬆的限制、以及 使用
any斷言,透過 顯式型別、適當的extends、以及 型別測試 可有效避免。 - 在 API 呼叫、表單驗證、狀態管理 等真實開發情境中,泛型函式已經是不可或缺的工具,熟練掌握它能讓你在大型專案中保持代碼的一致性與可靠性。
希望本篇文章能讓你對 TypeScript 泛型函式 有更深入的理解,並能在日常開發中靈活運用,寫出更乾淨、更安全的程式碼。祝你 Coding 順利,持續在 TypeScript 的世界裡成長!