本文 AI 產出,尚未審核

TypeScript 泛型約束(extends)完整教學

簡介

在 TypeScript 中,泛型 讓我們在撰寫函式、類別或介面時保持型別的彈性,同時仍能取得編譯期的型別安全性。
然而,完全不受限制的泛型有時會導致 型別過於寬鬆,使得函式內部無法安全地存取屬性或呼叫方法。這時就需要 泛型約束(generic constraints),透過 extends 關鍵字告訴編譯器「這個泛型必須符合某個型別」的條件。

掌握 extends 約束不只是語法上的需求,更是 提升程式可讀性、可維護性 的關鍵技巧。本文將從概念說明、實作範例、常見陷阱到實務應用,完整帶你了解並善用泛型約束。


核心概念

1. 為什麼需要約束?

  • 型別安全:在函式內部使用屬性或方法前,必須保證傳入的型別確實擁有這些成員。
  • 自我文件化extends 讓呼叫端一眼就能看出此泛型的使用限制,減少錯誤使用。
  • IDE 智慧提示:約束後的泛型會讓編輯器提供正確的自動完成與錯誤提示,開發體驗更好。

2. 基本語法

function foo<T extends Base>(arg: T): void { /* ... */ }
  • T 為泛型參數。
  • extends Base 表示 T 必須 至少 繼承(或符合)Base 的結構。
  • 若傳入的型別不符合此條件,編譯器會直接報錯。

3. 介面作為約束

使用介面定義約束條件是最常見的做法,因為介面只描述「形狀」而不牽涉實作。

interface HasLength {
  length: number;
}

4. 多重約束

TypeScript 允許同時使用多個介面或型別作為約束,只要使用交叉類型(&)即可。

function merge<T extends A & B>(obj: T) { /* ... */ }

5. 內建泛型約束

Array<T>Promise<T> 等內建類型已經使用了泛型約束,了解它們的實作方式有助於自訂類型。


程式碼範例

範例 1:最簡單的「長度」約束

interface HasLength {
  /** 任意具有 length 屬性的型別 */
  length: number;
}

/**
 * 回傳傳入值的長度
 * @param value 必須符合 HasLength 介面
 */
function getLength<T extends HasLength>(value: T): number {
  return value.length; // 編譯器保證 length 存在
}

// 使用
console.log(getLength("Hello"));          // 5
console.log(getLength([1, 2, 3, 4]));     // 4
// console.log(getLength(123)); // 編譯錯誤:number 沒有 length

重點T extends HasLengthvalue 必定擁有 length,即使傳入的是字串、陣列或自訂物件,都能安全存取。


範例 2:使用交叉類型的多重約束

interface HasId {
  id: string;
}
interface Timestamped {
  createdAt: Date;
}

/**
 * 合併兩個物件,返回同時具備 id 與 createdAt 的新物件
 */
function mergeWithMeta<T extends HasId & Timestamped>(obj: T): T {
  // 直接返回即可,編譯器已保證 obj 同時擁有 id 與 createdAt
  return { ...obj };
}

// 合法使用
const item = mergeWithMeta({
  id: "A001",
  createdAt: new Date(),
  name: "測試商品"
});

// 錯誤示範(缺少 createdAt)
// const bad = mergeWithMeta({ id: "B002", name: "缺少時間戳記" }); // 編譯錯誤

說明:交叉類型 HasId & TimestampedT 同時滿足兩個介面的需求,避免分別檢查。


範例 3:泛型約束與 keyof 搭配

/**
 * 從物件中挑選指定的屬性,返回的型別會自動映射
 * @param obj 任意物件
 * @param keys 欲取得的屬性鍵集合
 */
function pluck<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach(key => {
    result[key] = obj[key];
  });
  return result;
}

// 範例
const user = {
  id: 1,
  name: "Alice",
  age: 30,
  email: "alice@example.com"
};

const partial = pluck(user, ["id", "email"]);
/* partial 的型別為:
   {
     id: number;
     email: string;
   }
*/

要點K extends keyof T 限制 K 必須是 T 的屬性鍵,確保 obj[key] 的存取不會出錯。


範例 4:利用條件型別實作「可選屬性」的深層約束

interface DeepPartial<T> {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
}

/**
 * 合併深層部分更新物件
 */
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
  for (const key in source) {
    if (source[key] && typeof source[key] === "object") {
      // @ts-ignore: 這裡的遞迴型別仍符合 DeepPartial
      target[key] = deepMerge(target[key] as any, source[key] as any);
    } else if (source[key] !== undefined) {
      target[key] = source[key] as any;
    }
  }
  return target;
}

// 使用範例
type Config = {
  host: string;
  port: number;
  auth: {
    user: string;
    pass: string;
  };
};

const defaultConfig: Config = {
  host: "localhost",
  port: 8080,
  auth: { user: "admin", pass: "1234" }
};

const partialConfig: DeepPartial<Config> = {
  port: 3000,
  auth: { pass: "abcd" }
};

const merged = deepMerge(defaultConfig, partialConfig);
// merged 為完整的 Config,且型別安全

說明DeepPartial<T> 透過 條件型別extends 讓每層屬性都變成可選,結合 deepMerge 可安全地處理深層更新。


範例 5:限制泛型只能是 類別(使用 new () => T

/**
 * 建構任意類別的實例,類別必須有無參數建構子
 */
function createInstance<T>(ctor: new () => T): T {
  return new ctor();
}

// 定義兩個類別
class Foo {
  greet() { console.log("Hello from Foo"); }
}
class Bar {
  greet() { console.log("Hello from Bar"); }
}

// 正確使用
const foo = createInstance(Foo);
foo.greet(); // Hello from Foo

const bar = createInstance(Bar);
bar.greet(); // Hello from Bar

// 錯誤示範:沒有無參數建構子會編譯失敗
// class Baz { constructor(public x: number) {} }
// const baz = createInstance(Baz); // 編譯錯誤

重點new () => T 是一種特殊的泛型約束,用來限制傳入的參數必須是「可被 new 呼叫且無參數」的建構子。


常見陷阱與最佳實踐

陷阱 說明 解決方式
約束過於寬鬆 使用 any 或過度使用 unknown 會失去約束的意義。 只在必要時使用 unknown,並盡早縮小型別。
交叉類型失效 交叉類型若包含可選屬性,會產生 合併 行為,導致屬性變成必選。 明確標註屬性為可選 (?) 或使用 Partial<T> 包裝。
遞迴條件型別的編譯效能 深層遞迴條件型別(如 DeepPartial)在大型介面上可能導致編譯緩慢。 減少遞迴層級或分割介面,必要時加上 // @ts-ignore 暫時跳過。
keyof 與字串字面量的混用 keyof 會把數字索引視為 `string number`,有時會導致不必要的寬鬆。
類別建構子約束的可選參數 若類別只有帶參數的建構子,new () => T 會直接報錯。 重新設計類別,提供靜態工廠方法或使用 new (...args: any[]) => T

最佳實踐

  1. 先寫介面:先以介面描述需求,再把介面作為泛型約束的基礎。
  2. 盡量使用 readonly:在約束中加入 readonly 能防止意外修改。
  3. 保持單一責任:每個泛型約束只處理一件事,避免一次約束多個不相關的屬性。
  4. 善用 Pick / Partial:這兩個內建工具型別配合 extends 可快速產生子集合型別。
  5. 在函式簽名中加入說明:使用 JSDoc 或 TSDoc 註解說明約束的意圖,提升可讀性。

實際應用場景

  1. API 回傳資料的型別保證

    • 後端可能回傳多種形狀的 JSON,使用 extendskeyof 可在前端撰寫通用的資料處理函式,同時保證屬性存在。
  2. 表單驗證與資料映射

    • 透過 Pick<T, K>Partial<T>,可以根據表單欄位動態產生驗證規則,且型別安全。
  3. 資料庫 ORM 的模型映射

    • 例如使用 typeormsequelize 時,模型類別會有共同的屬性(id, createdAt),可用 T extends BaseEntity 來寫通用的 CRUD 函式。
  4. 插件系統(Plugin Architecture)

    • 每個插件必須實作特定介面(如 PluginInit),主程式透過 T extends PluginInit 來安全載入與呼叫插件方法。
  5. 函式式程式設計(Functional Programming)

    • 例如 map, filter 等高階函式,常使用 T extends (...args: any) => any 來限制參數必為函式,確保組合時不會出錯。

總結

  • 泛型約束 (extends) 是 TypeScript 提供的強大工具,讓我們在保持彈性的同時,仍能確保型別安全。
  • 透過 介面、交叉類型、條件型別 等手段,我們可以細緻地描述「必須具備什麼」的需求,避免在執行階段發生不可預期的錯誤。
  • 在實務開發中,善用約束能提升 程式碼可讀性、可維護性,尤其在大型專案、API 整合、插件系統等情境下,約束扮演關鍵角色。
  • 記得遵守 最佳實踐:先定義介面、保持單一責任、合理使用 Partial / Pick,並在文件中說明約束目的。

掌握了泛型約束之後,你的 TypeScript 程式碼將會變得更 堅固易於擴充,也能更好地發揮靜態型別帶來的開發優勢。祝你在 TypeScript 的世界裡寫出更安全、更優雅的程式碼!