TypeScript 類別泛型完整教學
簡介
在日常開發中,我們常會遇到需要 重複使用相同邏輯卻處理不同資料型別 的情境。傳統的 JavaScript 只能靠 any 或手動寫多個類別來因應,這樣不僅失去型別安全,也會造成程式碼冗長、維護成本高。
TypeScript 引入 類別泛型(Class Generics),讓我們可以在 同一個類別裡 以參數化的方式描述資料型別,從而同時享有 靜態型別檢查 與 高度的可重用性。
本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整掌握類別泛型的使用技巧,幫助 初學者到中階開發者 在專案中寫出更乾淨、更安全的程式碼。
核心概念
1. 為什麼需要類別泛型?
- 型別安全:編譯階段即能捕捉錯誤,避免執行時的意外。
- 程式碼復用:同一套邏輯可支援多種資料型別,避免重複撰寫。
- 可讀性提升:使用者能清楚看到類別在何種型別上運作,文件化程度更高。
2. 基本語法
在 TypeScript 中,泛型使用 尖括號 <T> 表示。對於類別而言,泛型參數放在類別名稱之後:
class Box<T> {
private _value: T;
constructor(value: T) {
this._value = value;
}
get value(): T {
return this._value;
}
set value(v: T) {
this._value = v;
}
}
T是一個 類型參數,在實例化時會被具體型別取代。- 任何屬性、方法的參數或回傳值,都可以使用
T來保持一致性。
3. 多個泛型參數
有時候一個類別需要同時管理多種型別,這時可以宣告 多個泛型參數:
class Pair<K, V> {
constructor(public key: K, public value: V) {}
}
K、V代表鍵與值的型別,使用時自行指定。
4. 泛型約束(Constraints)
若希望泛型必須具備特定屬性或方法,可使用 extends 來 限制型別:
interface Lengthwise {
length: number;
}
class Collection<T extends Lengthwise> {
private items: T[] = [];
add(item: T) {
console.log(`新增長度為 ${item.length} 的項目`);
this.items.push(item);
}
}
- 只有具備
length屬性的型別(如string、Array)才能作為T使用。
5. 預設泛型型別
有時候想提供 預設型別,讓使用者在不指定時仍能得到合理的行為:
class Cache<T = any> {
private store = new Map<string, T>();
set(key: string, value: T) {
this.store.set(key, value);
}
get(key: string): T | undefined {
return this.store.get(key);
}
}
程式碼範例
以下提供 5 個實用範例,說明類別泛型在不同情境下的應用與寫法。
範例 1️⃣:通用資料容器 Stack<T>
class Stack<T> {
private items: T[] = [];
/** 推入元素 */
push(item: T): void {
this.items.push(item);
}
/** 彈出元素,若空則回傳 undefined */
pop(): T | undefined {
return this.items.pop();
}
/** 目前的長度 */
get size(): number {
return this.items.length;
}
}
// 使用
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
const stringStack = new Stack<string>();
stringStack.push('hello');
stringStack.push('world');
console.log(stringStack.pop()); // 'world'
重點:
Stack<T>只在宣告一次後,就能支援任意型別的堆疊。
範例 2️⃣:鍵值對資料結構 Dictionary<K, V>
class Dictionary<K extends string | number, V> {
private map = new Map<K, V>();
set(key: K, value: V): void {
this.map.set(key, value);
}
get(key: K): V | undefined {
return this.map.get(key);
}
has(key: K): boolean {
return this.map.has(key);
}
}
// 使用
const userDict = new Dictionary<number, { name: string; age: number }>();
userDict.set(1, { name: 'Alice', age: 28 });
userDict.set(2, { name: 'Bob', age: 32 });
console.log(userDict.get(1)); // { name: 'Alice', age: 28 }
技巧:
K限制為string | number,確保Map的鍵能被正確比較。
範例 3️⃣:帶有約束的 SortableCollection<T>
interface Comparable {
compareTo(other: this): number; // <0 表示小於, =0 表示相等, >0 表示大於
}
class SortableCollection<T extends Comparable> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
/** 依 compareTo 進行升序排列 */
sort(): T[] {
return this.items.sort((a, b) => a.compareTo(b));
}
}
// 範例型別
class Person implements Comparable {
constructor(public name: string, public age: number) {}
compareTo(other: Person): number {
return this.age - other.age; // 年齡小的排前面
}
}
// 使用
const people = new SortableCollection<Person>();
people.add(new Person('Tom', 45));
people.add(new Person('Anna', 30));
people.add(new Person('Mike', 38));
console.log(people.sort().map(p => p.name)); // ['Anna', 'Mike', 'Tom']
關鍵:透過
extends Comparable,保證傳入的型別一定實作compareTo方法,使sort能安全運作。
範例 4️⃣:預設泛型的快取 Cache<T>
class Cache<T = any> {
private storage = new Map<string, T>();
/** 設定快取 */
set(key: string, value: T): void {
this.storage.set(key, value);
}
/** 取得快取 */
get(key: string): T | undefined {
return this.storage.get(key);
}
/** 清除快取 */
delete(key: string): boolean {
return this.storage.delete(key);
}
}
// 使用(未指定泛型,預設 any)
const anyCache = new Cache();
anyCache.set('foo', 123);
anyCache.set('bar', { x: 1 });
console.log(anyCache.get('bar')); // { x: 1 }
// 使用(指定泛型為 string)
const stringCache = new Cache<string>();
stringCache.set('greeting', 'Hello, world!');
console.log(stringCache.get('greeting')); // 'Hello, world!'
說明:若開發者不想指定型別,
any仍能保有彈性;若需要嚴格型別檢查,只要在宣告時提供具體型別即可。
範例 5️⃣:結合泛型與繼承的 EventEmitter<T>
type Listener<E> = (event: E) => void;
class EventEmitter<E> {
private listeners: Listener<E>[] = [];
/** 註冊監聽器 */
on(listener: Listener<E>): void {
this.listeners.push(listener);
}
/** 發送事件 */
emit(event: E): void {
for (const listener of this.listeners) {
listener(event);
}
}
}
// 定義事件型別
interface UserEvent {
type: 'login' | 'logout';
userId: number;
}
// 使用
const userEmitter = new EventEmitter<UserEvent>();
userEmitter.on(e => console.log(`User ${e.userId} ${e.type}`));
userEmitter.emit({ type: 'login', userId: 7 }); // User 7 login
userEmitter.emit({ type: 'logout', userId: 7 }); // User 7 logout
實務意義:透過泛型,
EventEmitter能在不同專案中重複使用,只要提供相對應的事件介面,即可得到完整的型別提示與檢查。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 忘記在實例化時提供型別 | 若類別有必須的泛型約束,未提供會導致 any,失去型別安全。 |
明確指定 <T>,或在類別內部提供預設型別。 |
過度使用 any |
為了省事直接使用 any,會讓編譯器失去檢查能力。 |
儘量使用 具體型別或泛型約束,保持靜態檢查。 |
| 約束不夠嚴謹 | 使用 extends object 仍允許不具備所需屬性的型別。 |
以 介面或型別別名 明確列出必須的屬性或方法。 |
泛型與 this 類型混用 |
在類別方法返回 this 時,若未使用 this 型別,子類別的鏈式呼叫會失效。 |
使用 this 型別(return this;)或 <U extends this> 進行泛型自參照。 |
遺忘 readonly |
若資料不應被外部修改,忘記加 readonly 會造成意外變更。 |
為不可變屬性加上 readonly,或在介面中定義為只讀。 |
最佳實踐:
- 盡量使用介面或型別別名 來描述泛型約束,讓意圖更清晰。
- 在公開 API 中提供預設泛型,降低使用門檻,同時保持彈性。
- 避免在同一檔案中混用多個相似的泛型類別,可考慮抽象成基礎類別或 mixin。
- 單元測試:對於泛型類別的行為(尤其是約束)寫測試,確保未來重構不會破壞型別安全。
實際應用場景
資料庫 ORM
- 每個資料表都對應一個 模型類別,使用
<T>讓Repository<T>能操作任意實體,且在編譯時即檢查欄位名稱與型別。
- 每個資料表都對應一個 模型類別,使用
表單驗證框架
Form<T>透過泛型描述表單欄位結構,getValue<K extends keyof T>(key: K)能保證返回值型別正確,減少手動類型斷言。
狀態管理(Redux / Vuex)
Store<S>中的S代表全局狀態型別,所有getState、dispatch都會根據S產生對應的型別提示,防止錯誤的 action payload。
通用 UI 元件
DataTable<T>只需要一個欄位描述columns: Array<keyof T>,即可自動產生類型安全的渲染邏輯,避免欄位寫錯或遺漏。
事件驅動系統
- 如前面的
EventEmitter<E>,在大型前端或後端專案中,透過統一的事件介面,讓不同模組之間的傳遞資料保持一致且可被編譯器檢查。
- 如前面的
總結
類別泛型是 TypeScript 提供的強大工具,讓我們能在 單一類別中 同時支援多種資料型別,同時保持 編譯時的型別安全。透過 泛型參數、約束、預設型別 等機制,我們可以:
- 寫出可重用、可維護的通用類別(如 Stack、Dictionary、Cache)。
- 在大型專案中保持 API 的一致性與可預測性(ORM、表單、狀態管理)。
- 避免常見的型別錯誤與程式碼冗餘,提升開發效率。
只要遵守 最佳實踐(明確約束、避免過度使用 any、加上 readonly),配合適當的單元測試,類別泛型將成為你在 TypeScript 生態系統中不可或缺的武器。快把今天學到的概念套用到實際專案中,讓程式碼更安全、更具彈性吧! 🚀