本文 AI 產出,尚未審核

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️⃣ 預設值的推斷順序

  1. 使用者明確指定foo<number>(123)Tnumber,忽略預設值。
  2. 型別推斷成功foo(123) → 編譯器根據傳入參數推斷 Tnumber,同樣不使用預設值。
  3. 推斷失敗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 = stringT = unknown
型別推斷被預設值「蓋掉」 若推斷失敗但仍能得到合理型別,編譯器會直接採用預設值,可能掩蓋錯誤。 在關鍵 API 中避免使用過於寬鬆的預設,或加上額外的型別檢查。
多層泛型相依 class Foo<A, B = A[]> 會因為 A 尚未確定而導致錯誤。 先確定前置泛型,再在後面的泛型使用 extendsinfer 取得限制。
與條件型別混用時的推斷行為 預設值可能會影響條件型別的分支選擇,導致不易預測的結果。 明確寫出 extends 限制,或在條件型別內使用 never 防止意外分支。

最佳實踐

  1. 預設值應該是「最常使用」的型別,而非「最寬鬆」的 any
  2. 在公開的函式庫或 API 中,使用預設值可以減少升級成本,但同時提供 overload 或泛型說明文件,避免使用者誤以為預設即唯一選項。
  3. 結合 extends 限制function foo<T extends string | number = string>(arg: T) {},保證預設值仍符合限制。
  4. 保持一致性:若同一個工具類別在多個檔案中使用相同的預設型別,建議抽成共用型別別名,避免不同檔案間的預設不一致。

實際應用場景

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 元件常會使用泛型來描述 childrenstyle

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 程式碼將更具可讀性、可維護性,也能在大型專案中減少不必要的型別宣告。趕快在自己的專案裡試試看,體會它帶來的開發效率提升吧! 🚀