本文 AI 產出,尚未審核

TypeScript – 泛型函式(Generic Functions)

簡介

在日常開發中,我們常會寫出需要接受不同型別參數的函式,例如 Array.prototype.mapPromise.then 等。若僅以 any 或聯合型別(union type)來描述,會失去 型別安全自動完成 的好處,導致錯誤只能在執行時才被發現。
泛型函式(generic functions)正是為了解決這個問題:它讓函式在呼叫時根據傳入的實際型別自動推斷返回值的型別,從而在編譯階段就捕捉到潛在錯誤,提升程式的可讀性與維護性。
本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹如何在 TypeScript 中撰寫與使用泛型函式,適合剛接觸 TypeScript 的初學者,也能為中階開發者提供進階技巧。


核心概念

1. 為什麼需要泛型函式?

  • 型別推斷:函式可以根據實際傳入的參數自動決定返回值的型別。
  • 避免 any:使用 any 會失去編譯期檢查,而泛型保持完整的型別資訊。
  • 重複使用:相同的邏輯可以在不同型別上重複使用,減少程式碼冗餘。

2. 基本語法

function identity<T>(value: T): T {
  return value;
}
  • <T>:在函式名稱後的尖括號中宣告一個類型參數 T
  • value: T:參數的型別使用剛才宣告的類型參數。
  • : T:返回值的型別同樣是 T,表示返回值與傳入值型別相同。

呼叫方式

const num = identity<number>(42);   // T 被推斷為 number
const str = identity('hello');     // T 自動推斷為 string

若在呼叫時省略類型參數,TypeScript 會根據實際參數自動推斷(稱為 type inference)。

3. 多個類型參數

有時候函式需要同時操作多個不同型別,此時可以宣告多個類型參數:

function merge<A, B>(obj1: A, obj2: B): A & B {
  return { ...obj1, ...obj2 };
}
  • A & B 為交叉型別,表示返回值同時擁有 AB 的屬性。

4. 限制類型參數(Constraints)

若希望類型參數只能是某個型別的子型別,可以使用 extends 來加上限制:

function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

此函式只接受具有 length 屬性的參數(如 stringArrayTypedArray 等),若傳入不符合的型別,編譯器會報錯。

5. 預設類型參數

在某些情況下,我們想為類型參數提供預設值:

function createArray<T = string>(length: number, value: T): T[] {
  return Array.from({ length }, () => value);
}

若呼叫時未指定 T,則預設為 string


程式碼範例

範例 1:基本的 identity 函式

function identity<T>(value: T): T {
  // 直接回傳傳入的值,型別保持不變
  return value;
}

// 使用
const num = identity(123);          // num 的型別是 number
const bool = identity(true);        // bool 的型別是 boolean
const arr = identity([1, 2, 3]);    // arr 的型別是 number[]

範例 2:使用多個類型參數的 merge 函式

function merge<A, B>(obj1: A, obj2: B): A & B {
  // 將兩個物件展開合併,返回交叉型別
  return { ...obj1, ...obj2 };
}

// 使用
const person = { name: 'Alice', age: 30 };
const job = { title: 'Engineer', salary: 80000 };

const employee = merge(person, job);
// employee 的型別為 { name: string; age: number; } & { title: string; salary: number; }
// 可以直接存取所有屬性
console.log(employee.name, employee.title);

範例 3:帶限制的 getProperty 函式

取得物件指定屬性的值,同時保證屬名一定存在於物件型別中。

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// 使用
const user = { id: 1, username: 'bob', isAdmin: false };
const username = getProperty(user, 'username'); // username 的型別是 string
// const wrong = getProperty(user, 'password'); // 編譯錯誤:'password' 不在 keyof user

範例 4:預設類型參數的 createArray

function createArray<T = string>(length: number, value: T): T[] {
  return Array.from({ length }, () => value);
}

// 使用
const nums = createArray<number>(3, 0); // [0, 0, 0],型別為 number[]
const strs = createArray(2, 'hi');      // ['hi', 'hi'],型別推斷為 string[]

範例 5:遞迴型別的 flatten(實務中常見的陣列扁平化)

type NestedArray<T> = (T | NestedArray<T>)[];

function flatten<T>(arr: NestedArray<T>): T[] {
  const result: T[] = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      result.push(...flatten(item)); // 再次呼叫自身,型別仍然是 T[]
    } else {
      result.push(item);
    }
  }
  return result;
}

// 使用
const mixed = [1, [2, [3, 4]], 5];
const flat = flatten(mixed); // flat 的型別是 number[]

此範例展示了 泛型遞迴 的威力:不論陣列層級多深,最終都會得到正確的元素型別。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記加上 extends 限制 若對類型參數未加限制,可能在函式內部使用不存在的屬性,導致編譯錯誤或 any 回退。 依需求使用 extends 限制,例如 T extends { length: number }
過度使用 any 為了避免型別錯誤而直接寫 any,失去泛型帶來的好處。 盡量讓 TypeScript 自動推斷,或使用 型別斷言as)在確定安全時才使用。
類型參數命名不具意義 使用單一的 TUV 會讓程式碼難以閱讀。 為類型參數取具體名稱,如 Item, Key, Result,提升可讀性。
返回值型別寫錯 若返回值的型別寫成 any 或與參數不一致,會失去型別檢查。 確保返回型別正確映射到類型參數(如 T[]A & B)。
遞迴型別深度過大 在遞迴泛型(如深層嵌套陣列)時,編譯器可能因型別過於複雜而報錯。 盡量限制遞迴深度或使用 unknown 作為中介型別,必要時拆分成多個函式。

最佳實踐

  1. 讓型別推斷自動化:盡量不在呼叫端顯式指定類型參數,除非推斷失敗或需要特別指定。
  2. 使用 keyof & 索引存取型別:在需要根據鍵名存取屬性時,配合 K extends keyof T 可保證鍵的正確性。
  3. 保持函式單一職責:泛型函式仍應遵守單一職責原則,過度複雜的泛型會降低可讀性。
  4. 寫測試:即使有型別保護,仍建議撰寫單元測試,確保邏輯正確。
  5. 工具輔助:使用 VS Code 的 IntelliSenseTypeScript Playground 來即時觀察型別推斷結果,快速驗證泛型寫法。

實際應用場景

  1. 資料轉換工具
    在前端與後端交互時,常需要把 API 回傳的 JSON 轉換成特定介面的物件。利用泛型函式可以寫出一次性、型別安全的 mapper

    function mapDto<T, U>(dto: T, mapper: (src: T) => U): U {
      return mapper(dto);
    }
    
  2. 通用資料結構(Stack、Queue、Tree)
    實作資料結構時,元素型別往往不固定。使用泛型函式搭配類別,可一次支援所有型別:

    class Stack<T> {
      private items: T[] = [];
      push(item: T) { this.items.push(item); }
      pop(): T | undefined { return this.items.pop(); }
    }
    
  3. 表單驗證函式庫
    表單欄位的驗證規則會根據欄位型別不同而不同。透過泛型,我們可以寫出 validate<T>(value: T, rule: (v: T) => boolean),保證傳入的驗證規則與值型別相符。

  4. React / Vue 組件的 Props 泛型
    在 UI 框架中,組件常需要根據傳入的 props 產生對應的型別。例如:

    type ListProps<T> = {
      items: T[];
      renderItem: (item: T) => React.ReactNode;
    };
    
    function List<T>({ items, renderItem }: ListProps<T>) {
      return <ul>{items.map(renderItem)}</ul>;
    }
    
  5. 函式式程式設計(Functional Programming)
    pipecompose 等高階函式常使用泛型來保證前後函式的輸入輸出相容:

    function pipe<A, B, C>(fn1: (a: A) => B, fn2: (b: B) => C) {
      return (a: A) => fn2(fn1(a));
    }
    

總結

泛型函式是 TypeScript 強大型別系統的核心之一,它讓我們在 保持型別安全 的同時,寫出 可重用可讀性高 的程式碼。透過 Textendskeyof 等語法,我們可以:

  • 讓函式根據呼叫時的實際型別自動推斷返回值型別。
  • 限制類型參數的範圍,避免非法操作。
  • 在多型別、交叉型別、遞迴型別等複雜情境下仍保持型別正確性。

在實務開發中,從簡單的 identitymerge 到資料結構、表單驗證、React 組件,泛型函式都扮演著不可或缺的角色。只要遵循 正確的限制清晰的命名適度的抽象,就能在專案中最大化地發揮 TypeScript 的型別優勢,降低 bug 數量、提升開發效率。

祝你在 TypeScript 的旅程中,玩得開心、寫得順手! 🚀