泛型介面(Generic Interface)
簡介
在大型 TypeScript 專案中,介面(interface)是描述物件形狀的核心工具。當資料結構需要在不同情境下使用不同型別時,僅靠普通介面往往會造成大量重複或失去型別安全。這時 泛型介面(Generic Interface)就派上用場:它允許我們在宣告介面的同時,保留型別參數的彈性,讓同一套介面可以適用於多種型別,同時仍能享有編譯期的型別檢查。
學會如何正確撰寫與使用泛型介面,不只可以 減少程式碼重複,還能提升 可讀性、維護性,以及在團隊協作時提供更明確的合約(contract)。本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整呈現泛型介面的使用方式,適合初學者到中級開發者快速上手。
核心概念
1. 基本語法:單一型別參數
最簡單的泛型介面只需要一個型別參數 T,它可以在介面內的屬性或方法簽名中被引用。
// 定義一個泛型介面,用於包裝任意型別的值
interface Box<T> {
/** 被包裝的資料 */
value: T;
/** 取得資料的說明文字 */
getInfo(): string;
}
// 使用 Number 作為型別參數
const numBox: Box<number> = {
value: 42,
getInfo() {
return `Number value is ${this.value}`;
},
};
// 使用 String 作為型別參數
const strBox: Box<string> = {
value: "Hello",
getInfo() {
return `String value is "${this.value}"`;
},
};
重點:
Box<T>中的T會在實例化時被具體型別取代,編譯器會自動推斷value與getInfo內的this.value為相對應的型別。
2. 多型別參數:同時描述多個型別
有時候介面需要同時操作多個型別,例如鍵值對(Key‑Value)結構。
// 定義一個雙型別參數的介面
interface Pair<K, V> {
key: K;
value: V;
/** 顯示鍵值對的文字描述 */
toString(): string;
}
// 使用 string 作為鍵、number 作為值
const pair1: Pair<string, number> = {
key: "age",
value: 30,
toString() {
return `${this.key}: ${this.value}`;
},
};
// 使用 number 作為鍵、boolean 作為值
const pair2: Pair<number, boolean> = {
key: 1,
value: true,
toString() {
return `#${this.key} => ${this.value}`;
},
};
技巧:若型別參數之間有「關聯」或「相依」的需求,可在宣告時加入 條件型別(Conditional Types)或 映射型別(Mapped Types)進一步限制。
3. 限制型別參數:使用 extends
有時我們只想接受符合特定結構的型別,例如必須擁有 length 屬性的陣列或字串。
// 只接受具有 length 屬性的型別
interface HasLength {
length: number;
}
// 泛型介面限定 T 必須繼承 HasLength
interface Collection<T extends HasLength> {
items: T;
/** 回傳長度 */
getLength(): number;
}
// 合法:Array、String 都符合 HasLength
const arrColl: Collection<string[]> = {
items: ["a", "b", "c"],
getLength() {
return this.items.length;
},
};
const strColl: Collection<string> = {
items: "TypeScript",
getLength() {
return this.items.length;
},
};
// 錯誤範例:number 沒有 length 屬性
// const numColl: Collection<number> = { ... } // 編譯錯誤
要點:
T extends HasLength讓編譯器在使用介面時自動檢查型別是否滿足HasLength,避免在執行期產生undefined錯誤。
4. 與類別結合:泛型介面作為類別的合約
介面常被用來規範類別的結構,加入泛型後,類別本身也能保持彈性。
// 定義一個泛型介面
interface Repository<T> {
/** 取得所有資料 */
findAll(): T[];
/** 依 ID 取得單筆資料 */
findById(id: number): T | undefined;
/** 新增資料 */
add(item: T): void;
}
// 以 User 為例實作 Repository
type User = { id: number; name: string };
class UserRepository implements Repository<User> {
private data: User[] = [];
findAll(): User[] {
return [...this.data];
}
findById(id: number) {
return this.data.find(u => u.id === id);
}
add(item: User) {
this.data.push(item);
}
}
好處:若未來要支援其他資料型別(例如
Product),只需要再建立class ProductRepository implements Repository<Product>,不必重新撰寫介面的結構。
5. 函式型別的泛型介面
介面也可以用來描述函式型別,結合泛型後,可寫出高度抽象且安全的回呼函式。
// 定義一個接受任意型別參數並回傳相同型別的函式介面
interface Mapper<T, R> {
(input: T): R;
}
// 例:將字串轉成長度
const lengthMapper: Mapper<string, number> = (s) => s.length;
// 例:將數字乘以二
const doubleMapper: Mapper<number, number> = (n) => n * 2;
// 使用泛型函式
function mapArray<T, R>(arr: T[], fn: Mapper<T, R>): R[] {
return arr.map(fn);
}
const nums = mapArray([1, 2, 3], doubleMapper); // [2,4,6]
const words = mapArray(["a", "ab", "abc"], lengthMapper); // [1,2,3]
說明:
Mapper<T, R>同時描述輸入與輸出型別,使mapArray能在編譯期即保證回傳陣列的正確型別。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 忘記限制型別參數 | 若不加 extends,使用者可以傳入不相容的型別,導致執行期錯誤。 |
為泛型介面加上必要的 型別約束(extends)。 |
| 過度抽象 | 把所有功能都寫成泛型會讓介面難以閱讀,失去可維護性。 | 只在需要彈性時 使用泛型,保持介面語意清晰。 |
| 型別推斷失敗 | 在某些情況下 TypeScript 無法自動推斷型別,需要手動指定。 | 在呼叫或實例化時 明確寫出型別參數(如 Box<number>)。 |
| 遞迴型別限制 | 使用遞迴型別(如樹結構)時,若未加 extends 會產生無窮遞迴。 |
使用 條件型別 或 分布式條件型別 斷開遞迴。 |
| 介面與類別同名衝突 | 同一檔案同名的介面與類別會被合併,可能產生意外行為。 | 避免同名,或使用 命名空間 明確區隔。 |
最佳實踐
- 盡量使用
readonly:在泛型介面中標示不可變屬性,提升資料安全。 - 配合
Partial、Pick、Omit:在需要部分屬性時,使用 TypeScript 內建的映射型別,避免重複定義。 - 文件化型別參數:在介面前加上 JSDoc 或註解,說明每個型別參數的意圖與限制。
- 測試型別:利用
tsd或dtslint等工具,寫型別測試,確保介面在升級後仍保持相容。
實際應用場景
1. API 回傳資料的通用包裝
後端常以 { data: T; error?: string } 的格式回傳。使用泛型介面一次定義:
interface ApiResponse<T> {
data: T;
error?: string;
}
// 取得使用者清單
async function fetchUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch("/api/users");
const json = await res.json();
return json;
}
// 取得單筆商品
async function fetchProduct(id: number): Promise<ApiResponse<Product>> {
// ...
}
2. 表單驗證規則
不同表單欄位的型別可能不一樣,但驗證介面可以抽象為泛型:
interface Validator<T> {
(value: T): string | null; // 回傳錯誤訊息或 null 表示通過
}
const requiredString: Validator<string> = (v) =>
v.trim() ? null : "此欄位為必填";
const positiveNumber: Validator<number> = (v) =>
v > 0 ? null : "必須大於 0";
3. 資料結構庫(如 LinkedList、Tree)
以泛型介面描述節點,讓同一套實作支援任意資料型別:
interface ListNode<T> {
value: T;
next?: ListNode<T>;
}
4. Redux / NgRx 狀態管理
Action 介面常帶有 payload,使用泛型即可一次搞定:
interface Action<T = any> {
type: string;
payload?: T;
}
const addTodo: Action<Todo> = {
type: "ADD_TODO",
payload: { id: 1, text: "學習 TypeScript" },
};
總結
泛型介面是 TypeScript 中提升程式彈性、減少重複、確保型別安全的關鍵工具。透過 型別參數、型別約束、多型別參數 以及 函式型別 的結合,我們可以為各種資料結構、API 回傳、狀態管理等場景建立 統一且可擴充的合約。在實作時,保持介面語意清晰、適度加上限制、避免過度抽象,才能發揮泛型介面的最大價值。
實務建議:在新專案的模型層或公共函式庫中,先以泛型介面設計核心資料結構,之後的功能擴充只需實作或繼承,不必重新撰寫重複的型別宣告,長遠來說能顯著降低維護成本。
祝你在 TypeScript 的世界裡,寫出既 安全 又 易讀 的程式碼! 🚀