TypeScript 泛型(Generics)── 預設泛型參數
簡介
在大型前端或 Node.js 專案中,型別安全是維持程式碼品質的關鍵。TypeScript 的泛型(Generics)讓我們在保留彈性的同時,仍能得到編譯期的型別檢查。
然而,當泛型在實作時需要 預設型別(default type parameter)時,許多開發者仍感到困惑:什麼時候需要預設值?怎麼寫才不會破壞型別推斷?
本篇文章將從概念切入,說明 預設泛型參數 的語法與運作原理,並提供 4 個實務範例、常見陷阱與最佳實踐,幫助你在日常開發中更自如地使用這項功能。
核心概念
1️⃣ 為什麼要有「預設」泛型?
- 減少重複:若大多數情況下使用同一個型別作為參數,寫預設值可以省去每次呼叫時的型別宣告。
- 提升相容性:在升級舊有 API 時,加入新的泛型參數而不破壞既有呼叫者,只要提供合理的預設值即可。
- 改善型別推斷:預設值會在型別推斷失敗時被採用,讓函式或類別仍能得到可用的型別。
2️⃣ 語法
在 TypeScript 中,預設泛型參數 的寫法與函式參數的預設值非常相似,只是放在泛型宣告的角括號內:
function foo<T = string>(arg: T): T {
return arg;
}
T = string表示:若使用者沒有明確指定T,則預設為string。- 預設值必須是 有效的型別(可以是基本型別、介面、型別別名或其他泛型)。
注意:預設值只能放在 最後 的泛型參數,或在之後的參數都已經有預設值(同函式參數的規則)。
3️⃣ 預設值的推斷順序
- 使用者明確指定:
foo<number>(123)→T為number,忽略預設值。 - 型別推斷成功:
foo(123)→ 編譯器根據傳入參數推斷T為number,同樣不使用預設值。 - 推斷失敗:
foo(undefined as any)→ 無法推斷,則使用預設值string。
程式碼範例
以下示範 4 個常見情境,從最簡單的函式到較複雜的類別與工具型別。
範例 1:簡單函式的預設泛型
// 預設 T 為 string
function identity<T = string>(value: T): T {
return value;
}
// 使用者未指定型別,會使用預設的 string
const a = identity('hello'); // a: string
// 明確指定為 number
const b = identity<number>(123); // b: number
// 推斷失敗,仍會得到 string
const c = identity(undefined as any); // c: string
說明:
identity函式在大多數情況下只會處理字串,讓使用者在少數需要其他型別時自行指定即可。
範例 2:帶有多個泛型的函式
// 第二個泛型有預設值,第一個沒有
function merge<A, B = Partial<A>>(a: A, b: B): A & B {
return { ...a, ...b };
}
interface User {
id: number;
name: string;
email: string;
}
// 只提供 A,B 會預設為 Partial<A>
const user = merge<User>({ id: 1, name: 'Tom', email: 'tom@example.com' }, { name: 'Tommy' });
// user 的型別為 User & Partial<User>,實際上等同於 User
// 明確指定 B 為 Pick<User, 'email'>
const user2 = merge<User, Pick<User, 'email'>>(
{ id: 2, name: 'Amy', email: '' },
{ email: 'amy@example.com' }
);
// user2 的型別為 User & Pick<User, 'email'>,仍然是 User
說明:
merge函式的第二個泛型B預設為Partial<A>,讓呼叫者在只想「部分更新」的情況下不必額外寫型別。
範例 3:類別的預設泛型參數
class Stack<T = number> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
// 預設為 number
const numStack = new Stack();
numStack.push(10); // OK
// numStack.push('hello'); // 編譯錯誤
// 指定為 string
const strStack = new Stack<string>();
strStack.push('hello'); // OK
說明:在實作資料結構時,預設型別可以為最常見的情境(例如
number),同時保留使用者自行指定的彈性。
範例 4:Utility Type – Promise 的預設型別
Promise 在 TypeScript 標準庫中已經使用了預設泛型:
// Promise<T = void>
function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 呼叫時未指定 T,預設為 void
delay(1000).then(() => console.log('done')); // 回傳值型別為 void
如果你自行實作類似的 Result 型別,也可以使用預設泛型:
type Result<T = unknown, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
function err<E = Error>(error: E): Result<never, E> {
return { ok: false, error };
}
// 使用預設的 unknown / Error
const r1 = ok(42); // Result<number, Error>
const r2 = err(new Error('Oops')); // Result<never, Error>
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 預設值放錯位置 | 只能在最後一個未提供預設值的泛型之後放預設值。 | 確認所有非最後的泛型都有明確指定或預設。 |
| 預設值過於寬鬆 | 例如把 T = any,會失去泛型帶來的型別保護。 |
使用更具體的預設,例如 T = string、T = unknown。 |
| 型別推斷被預設值「蓋掉」 | 若推斷失敗但仍能得到合理型別,編譯器會直接採用預設值,可能掩蓋錯誤。 | 在關鍵 API 中避免使用過於寬鬆的預設,或加上額外的型別檢查。 |
| 多層泛型相依 | class Foo<A, B = A[]> 會因為 A 尚未確定而導致錯誤。 |
先確定前置泛型,再在後面的泛型使用 extends 或 infer 取得限制。 |
| 與條件型別混用時的推斷行為 | 預設值可能會影響條件型別的分支選擇,導致不易預測的結果。 | 明確寫出 extends 限制,或在條件型別內使用 never 防止意外分支。 |
最佳實踐
- 預設值應該是「最常使用」的型別,而非「最寬鬆」的
any。 - 在公開的函式庫或 API 中,使用預設值可以減少升級成本,但同時提供 overload 或泛型說明文件,避免使用者誤以為預設即唯一選項。
- 結合
extends限制:function foo<T extends string | number = string>(arg: T) {},保證預設值仍符合限制。 - 保持一致性:若同一個工具類別在多個檔案中使用相同的預設型別,建議抽成共用型別別名,避免不同檔案間的預設不一致。
實際應用場景
1️⃣ 表單處理函式庫
在表單驗證庫中,常見的 validate<T = Record<string, any>>(data: T)。大多數表單資料都是物件,預設為 Record<string, any>,但開發者可以自行指定更精確的介面:
function validate<T = Record<string, any>>(data: T): boolean {
// 內部使用 keyof T 進行檢查
return true;
}
interface LoginForm {
username: string;
password: string;
}
validate<LoginForm>({ username: 'john', password: '1234' }); // 型別安全
validate({ foo: 1, bar: true }); // 使用預設 Record<string, any>
2️⃣ HTTP 客戶端
fetchJson<T = any>(url: string): Promise<T>:預設回傳 any(或 unknown)讓簡單呼叫不必指定型別;在需要型別安全的情況下,使用者自行提供:
interface Post {
id: number;
title: string;
body: string;
}
fetchJson<Post[]>('/posts').then(posts => {
// posts 被正確推斷為 Post[]
console.log(posts[0].title);
});
3️⃣ UI 元件庫的 Props
React 元件常會使用泛型來描述 children 或 style:
type BoxProps<T = 'div'> = {
as?: T;
children?: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;
function Box<T extends keyof JSX.IntrinsicElements = 'div'>(
props: BoxProps<T>
) {
const { as: Component = 'div', ...rest } = props;
return <Component {...rest} />;
}
// 預設為 <div>
<Box>Hello</Box>;
// 指定為 <section>
<Box as="section">Section content</Box>;
預設 as 為 'div',讓使用者在大多數情況下不必寫 as,同時保留彈性。
4️⃣ 資料結構的預設鍵型別
在實作 Map 的封裝時,鍵的型別常預設為 string:
class SimpleCache<K = string, V = any> {
private store = new Map<K, V>();
set(key: K, value: V) {
this.store.set(key, value);
}
get(key: K): V | undefined {
return this.store.get(key);
}
}
const cache = new SimpleCache(); // K 為 string, V 為 any
cache.set('user:1', { name: 'Alice' });
總結
- 預設泛型參數 為 TypeScript 提供了「彈性 + 型別安全」的最佳平衡。
- 正確的語法是
function foo<T = Default>(arg: T) {},且只能在最後的未預設參數之後使用。 - 透過 預設值,我們可以減少呼叫者的型別冗餘、提升舊有 API 的相容性,同時仍保有在需要時自行指定的自由度。
- 常見陷阱包括預設位置錯誤、過於寬鬆的預設、以及與條件型別混用時的推斷行為。遵循「預設最常用、限制最嚴格」的原則,搭配
extends限制,就能寫出既安全又友好的程式碼。 - 在 表單驗證、HTTP 客戶端、UI 元件庫、資料結構 等實務場景中,預設泛型已經成為不可或缺的工具。
掌握了預設泛型參數,你的 TypeScript 程式碼將更具可讀性、可維護性,也能在大型專案中減少不必要的型別宣告。趕快在自己的專案裡試試看,體會它帶來的開發效率提升吧! 🚀