TypeScript 泛型日常實用指南:寫出靈活又型別安全的程式碼
你正在撰寫一個可處理任何型別的函式,但不想失去型別安全。你可以使用 any,但這就違背了 TypeScript 的初衷。泛型讓你撰寫可處理多種型別的程式碼,同時保留型別資訊。本指南將展示如何在日常程式碼中使用泛型,而不會把事情搞得太複雜。
什麼是泛型?
泛型是一種建立可重用元件的方式,讓元件能適用於各種型別,而非僅限單一型別。它們讓你捕捉輸入的型別並用於輸出,在確保型別安全的同時保有靈活性。
把泛型想成型別參數。就像函式接受值參數一樣,泛型函式接受型別參數。例如 identity 函式:
function identity<T>(arg: T): T {
return arg;
}
在這裡,T 是型別參數。當你呼叫 identity<string>("hello") 時,TypeScript 會推斷 T 為 string,回傳型別即為 string。若傳入數字,則回傳數字。這比使用 any 更精確。
為什麼不直接用 any?
使用 any 會停用型別檢查。你將失去自動補全、重構支援,編譯器也無法捕捉錯誤。泛型讓型別資訊在程式碼中持續流動,因此你能同時獲得靈活性與安全性。
實務中的泛型函式
來看一個常見情境:回傳陣列第一個元素的函式。
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
沒有泛型時,你可能會寫成 function firstElement(arr: any[]): any,這就丟失了型別。有了泛型,若傳入 string[],回傳型別就是 string | undefined。
你也可以使用多個型別參數。例如,使用回呼將一個陣列映射到新陣列的函式:
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
這基本上就是 TypeScript 標準函式庫中 Array.prototype.map 的型別定義方式。
泛型介面與型別別名
泛型不限於函式。你可以建立泛型介面與型別別名,定義可重用的資料結構。
interface Box<T> {
value: T;
}
const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };
這對 API 回應、狀態管理或任何容器型別都很有用。
你也可以提供預設型別:
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
現在 ApiResponse 在沒有型別引數時會預設為 unknown。
泛型約束
有時你想限制泛型可使用的型別。你可以使用 extends 關鍵字來加入約束。
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
現在 T 必須具有 length 屬性。你可以呼叫 getLength("hello") 或 getLength([1,2,3]),但不能呼叫 getLength(42)。
當處理具有共同形狀的物件時,約束非常強大。
在類別中使用泛型
類別也可以是泛型的。常見例子是泛型集合:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
const popped = stack.pop(); // number | undefined
這確保只有數字能被推入數字堆疊。
實戰範例:型別安全的事件發送器
讓我們建立一個簡單的事件發送器,使用泛型將事件名稱對應到 payload 型別。
type EventMap = Record<string, any>;
class EventEmitter<T extends EventMap> {
private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};
on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.listeners[event]?.forEach(listener => listener(payload));
}
}
// 使用方式
interface MyEvents {
login: { userId: string };
logout: { userId: string };
}
const emitter = new EventEmitter<MyEvents>();
emitter.on('login', (payload) => {
console.log(payload.userId); // string
});
emitter.emit('login', { userId: '123' }); // OK
// emitter.emit('login', { userId: 123 }); // Error
這個模式為事件名稱與 payload 提供完整的型別安全,減少執行時期錯誤。
常見陷阱與最佳實踐
- 不要過度使用泛型。 如果函式只適用於特定型別,就不要讓它泛型化。
- 使用具描述性的型別參數名稱。
T對簡單情況沒問題,但TItem或TResponse可能更清楚。 - 偏好使用約束以避免執行時期錯誤。 若你需要某個屬性,就加上約束。
- 善用型別推斷。 通常你不需要明確傳入型別引數;TypeScript 可以自行推斷。
比較:泛型 vs. any vs. 聯合型別
| 做法 | 型別安全 | 靈活性 | 使用情境 |
|---|---|---|---|
| 泛型 | 高 | 高 | 保留型別的可重用元件 |
any |
無 | 高 | 快速原型開發、移轉 JS |
| 聯合型別 | 中 | 中 | 已知型別的有限集合 |
泛型在靈活性與安全性之間取得平衡,讓它們成為函式庫與共用工具的理想選擇。
常見問題
什麼時候該用泛型而不是聯合型別?
當輸入與輸出之間的型別關係很重要時,請使用泛型。聯合型別適用於值可以是固定幾種型別之一,但該特定型別不需要在操作之間被保留的情況。
泛型可以搭配箭頭函式使用嗎?
可以,但在 .tsx 檔案中語法可能較為棘手。你可以寫成 const identity = <T,>(arg: T): T => arg;,加上尾隨逗號以避免與 JSX 產生歧義。
如何將泛型約束為只接受物件?
使用 T extends object 或 T extends Record<string, unknown> 來確保 T 是物件型別。
泛型是 TypeScript 型別系統的基石。在日常程式碼中使用它們,你就能寫出既靈活又安全的函式、介面與類別。從簡單的泛型函式開始,再視需要逐步加入約束與更複雜的模式。想了解更多開發者工具,歡迎參考我們的 JSON Formatter,快速驗證並格式化你的 JSON 資料。