TypeScript 泛型 (Generics)
泛型 (Generics) 是 TypeScript 中非常核心且強大的功能,也是從 JavaScript 轉向 TypeScript 時最容易感到困惑的概念之一。
簡單來說,泛型就像是「型別的變數」。
在一般程式設計中,我們用變數來儲存「值」(例如 let age = 25);而在 TypeScript 中,我們用泛型來儲存「型別」。這讓我們在定義函式、介面或類別時,不需要先把型別寫死,而是等到實際使用的時候,再告訴程式說「這次我要用什麼型別」。
這樣做的好處是:程式碼可以更靈活、重用性更高,同時還能保有強大的型別檢查功能。
為什麼需要泛型?
讓我們從一個實際的場景來理解。假設我們需要寫一個「回傳輸入值」的函式:
情況 1:針對特定型別
如果我們只寫死特定的型別,那程式碼就變得沒有彈性。如果我們要處理 number 和 string,就得寫兩個函式:
// 只能處理數字
function identityNumber(value: number): number {
return value;
}
// 只能處理字串
function identityString(value: string): string {
return value;
}
這顯然很麻煩,且違反了 DRY (Don't Repeat Yourself) 原則。
情況 2:使用 any
為了讓函式可以接受任何型別,我們可能會想到用 any:
// 使用 any,雖然可以接受任何型別,但失去了型別檢查
function identityAny(value: any): any {
return value;
}
let result = identityAny(123);
// 這裡 result 的型別是 any,TypeScript 不知道它是 number
// 這導致我們無法使用 number 專屬的方法,也沒有錯誤檢查
使用 any 雖然解決了靈活性問題,但卻犧牲了 TypeScript 最重要的價值——型別安全。函式回傳後,我們完全失去了關於這個值的型別資訊。
情況 3:使用泛型 (Generics)
這就是泛型登場的時候了。我們可以在函式名稱後面加上 <T>(這個 T 是一個我們自定義的名稱,Type 的縮寫),用來代表「某種尚未決定的型別」。
// 定義一個泛型 T,表示輸入是 T 型別,回傳也是 T 型別
function identity<T>(value: T): T {
return value;
}
// 使用時:
// 1. 明確告訴 TypeScript,這次 T 是 number
let num = identity<number>(42);
//此時 num 的型別被正確推論為 number
// 2. 明確告訴 TypeScript,這次 T 是 string
let str = identity<string>('hello');
// str 是 string
// 3. 讓 TypeScript 自動推論 (最常用的方式)
let auto = identity('world');
// TypeScript 聰明地看出輸入是字串,所以自動把 T 推論為 string
透過泛型,我們既達到了 any 的靈活性,又保留了具體的型別資訊。
泛型函式
基本語法
泛型最常見的使用場景就是函式。語法是在函式名稱後加上 <型別參數>。
// <T> 宣告了一個型別變數 T
// (param: T) 表示參數 param 的型別是 T
// : T 表示回傳值的型別也是 T
function functionName<T>(param: T): T {
return param;
}
多個型別參數
有時候我們需要多個「型別變數」,習慣上我們會從 T 開始,接著用 U, V 等字母命名。
// 這個函式接受兩個不同型別的參數,並把它們包成一個 Tuple 回傳
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
// 明確指定 T 為 string, U 為 number
let result = pair<string, number>('age', 25);
// result 的型別是 [string, number]
// 自動推論
let auto = pair('name', '小明');
// auto 的型別是 [string, string]
泛型陣列
泛型也常跟陣列一起使用。例如我們想寫一個「取出陣列第一個元素」的通用函式:
// T[] 表示這是一個由 T 型別組成的陣列
// 回傳值可能是 T (如果陣列有東西) 或 undefined (如果陣列是空的)
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const numArr = [1, 2, 3];
const firstNum = getFirst(numArr); // T 自動推論為 number,結果是 number | undefined
const strArr = ['a', 'b', 'c'];
const firstStr = getFirst(strArr); // T 自動推論為 string,結果是 string | undefined
泛型約束 (Generic Constraints)
為什麼需要約束?
有時候,我們的泛型雖然是「某種型別」,但我們希望這個型別至少要具備某些特徵。
例如,我們想印出參數的 .length 屬性:
function logLength<T>(value: T): T {
// console.log(value.length); // 錯誤!因為 TypeScript 不確定 T 是否一定有 length 屬性
return value;
}
這時候我們需要「約束」這個 T,告訴 TypeScript:「我不管 T 是什麼,但它必須包含 length 屬性」。我們使用 extends 關鍵字來達成。
使用 extends 限制範圍
// 定義一個介面,描述「必須有 length 屬性」的結構
interface HasLength {
length: number;
}
// 限制 T 必須繼承自 (或符合) HasLength 介面
function logLength<T extends HasLength>(value: T): T {
console.log(value.length); // 現在可以安心存取 length 了
return value;
}
// 測試:
logLength('hello'); // OK,因為字串有 length
logLength([1, 2, 3]); // OK,因為陣列有 length
logLength({ length: 5 }); // OK,因為這個物件符合 HasLength 的結構
// logLength(123); // 錯誤!因為 number 沒有 length 屬性
使用 keyof 進行約束
這是一個非常實用的技巧,特別是在處理物件屬性存取時。我們希望確保我們存取的「屬性名稱」是真的存在於「物件」中的。
// K extends keyof T 意思是:
// K 必須是 T 的所有屬性名稱 (keys) 中的其中一個
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
name: '小明',
age: 25,
};
// 這裡 T 是 user 的型別,而 keyof T 就是 "name" | "age"
let name = getProperty(user, 'name'); // OK,回傳 string
let age = getProperty(user, 'age'); // OK,回傳 number
// let error = getProperty(user, "email");
// 錯誤!因為 "email" 不是 user 的屬性之一
// 透過泛型約束,TypeScript 幫我們擋下了潛在的 bug
多重約束
如果一個泛型需要同時符合多個介面,可以使用 & (Intersection Types) 連接。
interface Named {
name: string;
}
interface Aged {
age: number;
}
// T 必須同時擁有 name 和 age 屬性
function printPerson<T extends Named & Aged>(person: T): void {
console.log(`${person.name}, ${person.age} 歲`);
}
printPerson({ name: '小明', age: 25 }); // OK
// printPerson({ name: "小明" }); // 錯誤,缺了 age
泛型介面 (Generic Interfaces)
除了函式,介面 (Interface) 也可以是泛型的。這在定義像 API 回應格式、容器結構時非常有用。
// 定義一個通用的容器介面,內容物是由 T 決定的
interface Container<T> {
value: T;
getValue(): T;
setValue(value: T): void;
}
// 實作時再決定 T 是什麼
// 這裡雖然還沒決定 T,但 Box 類別本身也變成了泛型類別
class Box<T> implements Container<T> {
constructor(public value: T) {}
getValue(): T {
return this.value;
}
setValue(value: T): void {
this.value = value;
}
}
const numberBox = new Box<number>(42); // 專門裝數字的盒子
const stringBox = new Box<string>('hello'); // 專門裝字串的盒子
泛型類別 (Generic Classes)
類別使用泛型的方式與介面類似,這讓數據結構(如 Stack, Queue)的實作變得非常方便且安全。
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
// 數字堆疊
const numberStack = new Stack<number>();
numberStack.push(1);
// numberStack.push("a"); // 錯誤檢查生效,不能放入字串
// 字串堆疊
const stringStack = new Stack<string>();
stringStack.push('a');
泛型型別別名 (Generic Type Aliases)
type 關鍵字同樣支援泛型。這在定義複雜的物件結構或工具型別時很常見。
// 定義一個可能為 null 的型別
type Nullable<T> = T | null;
// 定義 API 結果的通用結構:成功有 value,失敗有 error
type Result<T, E> = { success: true; value: T } | { success: false; error: E };
// 使用範例
type User = { id: number; name: string };
function fetchUser(): Result<User, string> {
// 模擬成功
return { success: true, value: { id: 1, name: '小明' } };
// 模擬失敗
// return { success: false, error: 'Network Error' };
}
泛型預設值 (Generic Default Values)
就像函式參數可以有預設值一樣,泛型參數也可以設定預設型別。當長用者沒有特別指定 <T> 時,就會使用預設值。
// 如果沒傳 T,預設 T 為 string
interface Config<T = string> {
value: T;
}
let config1: Config = { value: 'hello' }; // 這裡 T 自動採用 string
let config2: Config<number> = { value: 42 }; // 這裡明確覆寫為 number
進階:條件型別中的泛型 (Conditional Types)
這是 TypeScript 中比較進階且強大的用法,類似程式中的三元運算子 Condition ? True : False,只是用在型別上。
// 如果 T 是 string 的子型別,結果就是 'text',否則就是 'other'
type TypeName<T> = T extends string ? 'text' : 'other';
type A = TypeName<string>; // 'text'
type B = TypeName<'abc'>; // 'text'
type C = TypeName<number>; // 'other'
這在製作工具型別(Utility Types)時非常常用。
進階:infer 關鍵字
infer 是搭配 extends 使用的關鍵字,用來「推論」並「提取」型別中的某個部分。可以把它想像成在型別比對過程中,順便宣告一個變數來抓取型別。
範例:提取函式的回傳型別
TypeScript 內建的 ReturnType 就是這樣實作的:
// 解讀:
// 如果 T 是一個函式 ( (...args: any[]) => any )
// 且這個函式的回傳型別我們用 R 來暫存 (infer R)
// 那麼結果就是 R,否則就是 never
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function greet() {
return 'hello';
}
// TypeScript 自動推論出 greet 的回傳是 string,所以 R 抓到了 string
type GreetRet = MyReturnType<typeof greet>; // string
範例:提取 Promise 內部的型別
// 解讀:如果 T 是一個 Promise,那就把 Promise 包住的型別抓出來變成 U
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type ResultString = UnpackPromise<Promise<string>>; // string
type ResultNumber = UnpackPromise<Promise<number>>; // number
type ResultNormal = UnpackPromise<number>; // number (因為不符合 extends Promise,回傳原本的 T)
泛型在常用函式的應用
其實我們在日常開發中經常使用泛型,特別是在陣列操作上。
map
// map 其實就是一個泛型函式
// T 是原陣列元素的型別
// U 是轉化後新陣列元素的型別
const numbers = [1, 2, 3];
const strings = numbers.map<string>((n) => n.toString());
// 這裡 map 自動推論出 T=number, U=string
Promise
非同步操作中,我們幾乎一定會用到 Promise 的泛型來指定「未來會拿到的資料型別」。
// 指定這個 Promise 最終會 resolve 出一個 number
const myPromise = new Promise<number>((resolve) => {
resolve(100);
});
myPromise.then((val) => {
// val 被正確推論為 number
console.log(val + 1);
});
總結
泛型是 TypeScript 邁向高階開發的基石。雖然語法一開始看起來充滿了 <T>, <U> 有點嚇人,但只要記住它的核心概念:它只是將型別參數化,讓我們寫出更通用的程式碼。
掌握泛型後,你將能:
- 寫出共用性極高的元件與函式。
- 閱讀並理解複雜的第三方套件型別定義。
- 在保持程式碼彈性的同時,不犧牲任何一點型別安全。