在 TypeScript 中使用泛型
1. 定義一個可以安全操作不同型別資料的類別或函式:定義一個泛型參數
泛型(Generics)允許我們在定義類別或函式時,使用一個「佔位型別」(泛型參數),讓程式碼可以靈活處理不同型別的資料,並保持型別安全。
範例:定義一個泛型函式
我們定義一個函式,用來回傳輸入的資料,並使用泛型參數來確保輸入與輸出的型別一致。
// 定義一個泛型函式,T 是泛型參數
function identity<T>(value: T): T {
return value;
}
// 使用範例
const stringResult = identity<string>("Hello, TypeScript!"); // 明確指定 T 為 string
const numberResult = identity<number>(42); // 明確指定 T 為 number
const booleanResult = identity(true); // TypeScript 會自動推斷 T 為 boolean
console.log(stringResult); // 輸出: Hello, TypeScript!
console.log(numberResult); // 輸出: 42
console.log(booleanResult); // 輸出: true
解釋:
-
<T>
是泛型參數,T 是一個型別變數,代表某個型別。 -
函式 identity 接受一個型別為 T 的參數 value,並回傳相同型別 T 的值。
-
在呼叫時,可以明確指定型別(例如
identity<string>
),或讓 TypeScript 自動推斷型別(例如identity(true)
)。
範例:定義一個泛型類別
我們定義一個泛型類別來儲存和管理某種型別的資料。
// 定義一個泛型類別
class Box<T> {
private content: T;
constructor(value: T) {
this.content = value;
}
getContent(): T {
return this.content;
}
setContent(value: T): void {
this.content = value;
}
}
// 使用範例
const stringBox = new Box<string>("TypeScript");
console.log(stringBox.getContent()); // 輸出: TypeScript
const numberBox = new Box<number>(100);
console.log(numberBox.getContent()); // 輸出: 100
解釋:
-
Box<T>
是一個泛型類別,T 是泛型參數。 -
content 屬性的型別是 T,確保儲存的資料與指定的型別一致。
-
建構函式和方法都使用 T 來保持型別安全。
2. 決定泛型參數的實際型別:在初始化類別或呼叫函式時填入泛型引數
在初始化泛型類別或呼叫泛型函式時,我們可以明確指定泛型參數的實際型別,或依靠 TypeScript 的型別推斷。
範例:明確指定泛型引數
// 泛型函式
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
// 明確指定 T 為 { name: string },U 為 { age: number }
const merged = merge<{ name: string }, { age: number }>(
{ name: "Alice" },
{ age: 25 }
);
console.log(merged); // 輸出: { name: "Alice", age: 25 }
// 型別推斷
const inferred = merge({ name: "Bob" }, { age: 30 });
console.log(inferred); // 輸出: { name: "Bob", age: 30 }
範例:初始化泛型類別
// 定義泛型類別
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(2);
console.log(numberStack.pop()); // 輸出: 2
// 型別推斷
const stringStack = new Stack(); // TypeScript 推斷為 Stack<string>
stringStack.push("Hello");
console.log(stringStack.pop()); // 輸出: Hello
解釋:
-
在 merge 函式中,可以明確指定 T 和 U 的型別,或者讓 TypeScript 根據傳入的參數自動推斷。
-
在 Stack 類別中,初始化時可以指定型別(例如
Stack<number>
),或省略型別讓 TypeScript 推斷。
TypeScript 型別推斷的運作方式
TypeScript 的型別推斷是一種自動推導變數或物件型別的功能,當你沒有明確指定型別時,TypeScript 會根據上下文和使用方式來推斷最適合的型別。在你的例子中,Stack 是一個泛型類別,定義如下:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
這裡的 T 是一個泛型參數,代表 Stack 類別可以處理任意型別的資料。當你創建一個 Stack 實例時,TypeScript 會嘗試根據上下文來推斷 T 的具體型別。
為什麼 stringStack 被推斷為 Stack<string>
?
在你的程式碼中:
const stringStack = new Stack(); // TypeScript 推斷為 Stack<string>
stringStack.push("Hello");
console.log(stringStack.pop()); // 輸出: Hello
當你寫 const stringStack = new Stack();
時,TypeScript 並沒有立即確定 T 的型別,因為你沒有明確指定泛型參數(例如 new Stack<string>()
)。這時,TypeScript 會進入一種「待定」狀態,等待後續的上下文來推斷 T 的型別。
在下一行:
stringStack.push("Hello");
你呼叫了 stringStack.push("Hello")
,而 "Hello" 是一個字面量型別 string。根據 push 方法的定義:
push(item: T): void
TypeScript 會推斷 item 的型別是 string,進而推斷泛型參數 T 必須是 string。因此,整個 stringStack 的型別被推斷為 Stack<string>
。
這種推斷是基於 TypeScript 的上下文型別推斷(Contextual Typing),它會根據你如何使用變數或方法來推導型別。
為什麼不是其他型別?
你可能會想:為什麼 TypeScript 不會推斷成 Stack<any>
或其他型別?這是因為 TypeScript 會盡量選擇最具體的型別來確保型別安全。在這個例子中:
-
你在 push 方法中傳入了一個 string 型別的值
("Hello")
。 -
TypeScript 根據這個值推斷 T 為 string,因為這是最符合上下文的型別。
-
如果你後續再呼叫 stringStack.push(123),TypeScript 會因為型別不匹配(123 是 number,而 T 已被推斷為 string)而報錯,這確保了型別安全。
如果不呼叫 push,會發生什麼?
如果你只寫了以下程式碼:
const stringStack = new Stack();
而沒有後續的 push("Hello")
,TypeScript 無法從上下文推斷 T 的型別。在這種情況下,TypeScript 會將 T 推斷為 any,也就是 Stack<any>
。這是因為 TypeScript 沒有足夠的資訊來判斷 T 應該是什麼型別。
例如:
const stack = new Stack();
stack.push("Hello");
stack.push(123); // 不會報錯,因為 T 被推斷為 any
在這種情況下,Stack<any>
會允許任何型別的資料被推入堆疊,這可能會導致型別安全問題。因此,建議在創建泛型類別實例時,明確指定型別(例如 new Stack<string>()
),以避免不必要的 any 推斷。
程式碼完整範例與說明
為了讓你更清楚理解,以下是完整的程式碼,並附上詳細的註解:
// 定義泛型類別 Stack<T>
class Stack<T> {
private items: T[] = []; // 儲存 T 型別的陣列
// 將 item 推入堆疊
push(item: T): void {
this.items.push(item);
}
// 從堆疊彈出並返回頂端元素
pop(): T | undefined {
return this.items.pop();
}
}
// 明確指定型別為 number
const numberStack = new Stack<number>();
numberStack.push(1); // 推入數字 1
numberStack.push(2); // 推入數字 2
console.log(numberStack.pop()); // 輸出: 2
// 型別推斷的例子
const stringStack = new Stack(); // 初始時 T 尚未確定
stringStack.push("Hello"); // 推入字串 "Hello",T 被推斷為 string
console.log(stringStack.pop()); // 輸出: Hello
// 如果不指定型別且無上下文,T 會是 any
const anyStack = new Stack(); // T 推斷為 any
anyStack.push("Hello"); // 可以推入字串
anyStack.push(123); // 也可以推入數字
console.log(anyStack.pop()); // 輸出: 123
如何避免型別推斷為 any?
為了確保型別安全,建議在創建泛型類別實例時,明確指定泛型參數。例如:
const stringStack = new Stack<string>(); // 明確指定 T 為 string
stringStack.push("Hello"); // 正確
stringStack.push(123); // 錯誤:TypeScript 會報錯,因為 123 不是 string
這樣可以避免 TypeScript 推斷為 any,並確保你的程式碼在編譯時期就能捕捉到型別錯誤。
總結
-
為什麼 stringStack 被推斷為
Stack<string>
?- 因為你在
stringStack.push("Hello")
中傳入了一個 string 型別的值,TypeScript 根據上下文推斷泛型參數 T 為 string。
- 因為你在
-
型別推斷的機制:
-
TypeScript 會根據你如何使用泛型類別(例如呼叫 push 方法時傳入的值)來推斷 T 的型別。
-
如果沒有足夠的上下文,TypeScript 會推斷 T 為 any。
-
-
建議:
-
為了型別安全,建議明確指定泛型參數(例如
new Stack<string>()
)。 -
避免僅依賴型別推斷,特別是在泛型類別的使用情境中。
-
3. 繼承泛型類別:建立一個類別來繼承泛型類別,並沿用、鎖定或限制從父類別繼承而來的泛型型別
我們可以讓子類別繼承泛型父類別,並根據需求沿用、鎖定或限制泛型型別。
範例:繼承泛型類別
// 定義泛型父類別
class Container<T> {
protected item: T;
constructor(item: T) {
this.item = item;
}
getItem(): T {
return this.item;
}
}
// 沿用泛型型別
class StringContainer extends Container<string> {
constructor(item: string) {
super(item);
}
describe(): string {
return `This is a string container with value: ${this.item}`;
}
}
// 限制泛型型別
interface Printable {
print(): string;
}
class PrintableContainer<T extends Printable> extends Container<T> {
printItem(): string {
return this.item.print();
}
}
// 使用範例
const stringContainer = new StringContainer("Test");
console.log(stringContainer.describe()); // 輸出: This is a string container with value: Test
class Book implements Printable {
constructor(private title: string) {}
print(): string {
return `Book: ${this.title}`;
}
}
const bookContainer = new PrintableContainer(new Book("TypeScript Guide"));
console.log(bookContainer.printItem()); // 輸出: Book: TypeScript Guide
解釋:
-
StringContainer 繼承
Container<string>
,鎖定泛型型別為 string。 -
PrintableContainer 繼承 Container
<T>
,並限制 T 必須實現 Printable 介面(使用 extends Printable)。 -
子類別可以新增自己的方法(例如 describe 或 printItem),並利用父類別的泛型屬性。
4. 對泛型使用型別防衛敘述:使用型別謂詞函式
型別謂詞(Type Predicate)是一種特殊的回傳型別,用來縮小泛型參數的型別範圍。
範例:使用型別謂詞
// 定義一個介面
interface Animal {
makeSound(): string;
}
// 型別謂詞函式
function isString<T>(value: T): value is T & string {
return typeof value === "string";
}
// 泛型函式使用型別防衛
function processValue<T>(value: T): string {
if (isString(value)) {
// 在此分支中,TypeScript 知道 value 是 string
return `String value: ${value.toUpperCase()}`;
}
return `Non-string value: ${value}`;
}
// 使用範例
console.log(processValue("Hello")); // 輸出: String value: HELLO
console.log(processValue(123)); // 輸出: Non-string value: 123
解釋:
-
isString 是一個型別謂詞函式,回傳 value is T & string,表示如果條件成立,value 的型別會被縮小為 string。
-
在 processValue 中,使用 isString 檢查後,TypeScript 會自動將 value 視為 string,允許使用 string 的方法(例如 toUpperCase)。
5. 在泛型類別加入獨立函式:定義靜態方法
靜態方法(static method)是類別層級的方法,不需要實例化即可呼叫。我們可以在泛型類別中定義靜態方法。
範例:泛型類別與靜態方法
class Repository<T> {
private items: T[] = [];
static createEmpty<T>(): Repository<T> {
return new Repository<T>();
}
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return this.items;
}
}
// 使用範例
const numberRepo = Repository.createEmpty<number>();
numberRepo.add(1);
numberRepo.add(2);
console.log(numberRepo.getAll()); // 輸出: [1, 2]
const stringRepo = Repository.createEmpty<string>();
stringRepo.add("Apple");
stringRepo.add("Banana");
console.log(stringRepo.getAll()); // 輸出: ["Apple", "Banana"]
解釋:
-
createEmpty 是一個靜態方法,使用泛型參數 T 來創建一個空的 Repository 實例。
-
靜態方法不需要實例化即可呼叫,適合用來提供工廠方法或工具方法。
6. 定義一個泛型功能但不實作之:以泛型參數定義一個介面
我們可以使用泛型來定義介面,描述某種功能的結構,但不提供具體實作。
範例:泛型介面
// 定義泛型介面
interface DataProcessor<T> {
process(data: T): T;
}
// 實作泛型介面
class StringProcessor implements DataProcessor<string> {
process(data: string): string {
return data.toUpperCase();
}
}
class NumberProcessor implements DataProcessor<number> {
process(data: number): number {
return data * 2;
}
}
// 使用範例
const stringProcessor = new StringProcessor();
console.log(stringProcessor.process("hello")); // 輸出: HELLO
const numberProcessor = new NumberProcessor();
console.log(numberProcessor.process(5)); // 輸出: 10
解釋:
-
DataProcessor<T>
是一個泛型介面,定義了一個 process 方法,接受型別 T 的輸入並回傳型別 T。 -
StringProcessor 和 NumberProcessor 分別實作 DataProcessor,並指定 T 為 string 或 number。
-
泛型介面允許我們定義通用的功能藍圖,讓不同的類別以不同型別實現。
總結與建議
-
泛型的核心優勢:提供型別安全和程式碼重用性,讓函式或類別可以處理多種型別而不失嚴謹。
-
操作步驟建議:
-
定義泛型時,使用
<T>
(或其他字母)作為型別佔位符。 -
在使用時,根據需要明確指定型別或依賴 TypeScript 推斷。
-
繼承泛型類別時,考慮是否需要鎖定或限制型別(例如 extends)。
-
使用型別謂詞來縮小型別範圍,增強型別檢查的靈活性。
-
在類別中加入靜態方法來提供便利的初始化或工具功能。
-
使用泛型介面來定義抽象功能,方便不同類別實現。
-
-
實務建議:在前端開發中,泛型常用於處理 API 回應資料、表單資料結構或元件屬性(props)。建議從簡單的泛型函式開始練習,逐步應用到類別和介面。