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 HasLength讓value必定擁有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 & Timestamped讓T同時滿足兩個介面的需求,避免分別檢查。
範例 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。 |
最佳實踐
- 先寫介面:先以介面描述需求,再把介面作為泛型約束的基礎。
- 盡量使用
readonly:在約束中加入readonly能防止意外修改。 - 保持單一責任:每個泛型約束只處理一件事,避免一次約束多個不相關的屬性。
- 善用
Pick/Partial:這兩個內建工具型別配合extends可快速產生子集合型別。 - 在函式簽名中加入說明:使用 JSDoc 或 TSDoc 註解說明約束的意圖,提升可讀性。
實際應用場景
API 回傳資料的型別保證
- 後端可能回傳多種形狀的 JSON,使用
extends與keyof可在前端撰寫通用的資料處理函式,同時保證屬性存在。
- 後端可能回傳多種形狀的 JSON,使用
表單驗證與資料映射
- 透過
Pick<T, K>與Partial<T>,可以根據表單欄位動態產生驗證規則,且型別安全。
- 透過
資料庫 ORM 的模型映射
- 例如使用
typeorm、sequelize時,模型類別會有共同的屬性(id,createdAt),可用T extends BaseEntity來寫通用的 CRUD 函式。
- 例如使用
插件系統(Plugin Architecture)
- 每個插件必須實作特定介面(如
PluginInit),主程式透過T extends PluginInit來安全載入與呼叫插件方法。
- 每個插件必須實作特定介面(如
函式式程式設計(Functional Programming)
- 例如
map,filter等高階函式,常使用T extends (...args: any) => any來限制參數必為函式,確保組合時不會出錯。
- 例如
總結
- 泛型約束 (
extends) 是 TypeScript 提供的強大工具,讓我們在保持彈性的同時,仍能確保型別安全。 - 透過 介面、交叉類型、條件型別 等手段,我們可以細緻地描述「必須具備什麼」的需求,避免在執行階段發生不可預期的錯誤。
- 在實務開發中,善用約束能提升 程式碼可讀性、可維護性,尤其在大型專案、API 整合、插件系統等情境下,約束扮演關鍵角色。
- 記得遵守 最佳實踐:先定義介面、保持單一責任、合理使用
Partial/Pick,並在文件中說明約束目的。
掌握了泛型約束之後,你的 TypeScript 程式碼將會變得更 堅固、易於擴充,也能更好地發揮靜態型別帶來的開發優勢。祝你在 TypeScript 的世界裡寫出更安全、更優雅的程式碼!