本文 AI 產出,尚未審核

泛型介面(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 會在實例化時被具體型別取代,編譯器會自動推斷 valuegetInfo 內的 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 會產生無窮遞迴。 使用 條件型別分布式條件型別 斷開遞迴。
介面與類別同名衝突 同一檔案同名的介面與類別會被合併,可能產生意外行為。 避免同名,或使用 命名空間 明確區隔。

最佳實踐

  1. 盡量使用 readonly:在泛型介面中標示不可變屬性,提升資料安全。
  2. 配合 PartialPickOmit:在需要部分屬性時,使用 TypeScript 內建的映射型別,避免重複定義。
  3. 文件化型別參數:在介面前加上 JSDoc 或註解,說明每個型別參數的意圖與限制。
  4. 測試型別:利用 tsddtslint 等工具,寫型別測試,確保介面在升級後仍保持相容。

實際應用場景

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 的世界裡,寫出既 安全易讀 的程式碼! 🚀