本文 AI 產出,尚未審核

TypeScript 泛型(Generics)—— 泛型與陣列 / 物件結合


簡介

在 TypeScript 中,泛型讓我們在保持型別安全的同時,寫出高度可重用的程式碼。尤其在處理 陣列物件 這類容器時,若不使用泛型,往往只能得到 any[]Record<string, any>,失去編譯期的型別檢查與自動完成支援。

本單元將說明「泛型與陣列 / 物件結合」的核心概念,透過實作範例展示如何在常見的資料結構上套用泛型,並分享在實務開發中容易踩到的陷阱與最佳實踐。即使你是剛接觸 TypeScript 的新手,也能在閱讀完本文後,立刻在專案中運用泛型提升程式的可維護性與可靠度。


核心概念

1. 基本的陣列泛型

在 TypeScript 中,陣列的型別可以寫成 T[]Array<T>,兩者等價。使用泛型,我們可以宣告一個接受任意型別元素的函式,並在返回值上保留該型別資訊。

// 取得陣列的第一個元素,返回的型別與傳入陣列的元素型別相同
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

// 使用範例
const numArr = [10, 20, 30];
const strArr = ["apple", "banana", "cherry"];

const firstNum = first(numArr); // 型別為 number | undefined
const firstStr = first(strArr); // 型別為 string | undefined

重點T 會在呼叫 first 時自動被推斷為 numberstring,因此編譯器能正確提示 firstNumfirstStr 的型別。


2. 多型別陣列(Tuple)與泛型

Tuple 是固定長度且每個位置型別可能不同的陣列。結合泛型,我可以寫出支援任意長度 Tuple 的工具函式。

// 取得 Tuple 最後一個元素,返回的型別會根據 Tuple 的最後一個型別推斷
function last<T extends any[]>(tuple: [...T]): T extends [...infer _, infer L] ? L : never {
  return tuple[tuple.length - 1] as any;
}

// 範例
const tuple1 = [1, "two", true] as const;
const tuple2 = ["first", 2] as const;

const last1 = last(tuple1); // 型別為 true
const last2 = last(tuple2); // 型別為 2

此範例利用 條件型別 (T extends [...infer _, infer L] ? L : never) 取得最後一個元素的型別,讓開發者在使用 last 時仍能得到完整的型別資訊。


3. 泛型物件:鍵值對的型別映射

當我們想要把一組鍵 (key) 與對應的值 (value) 包裝成物件時,泛型提供了 映射型別 (Mapped Types) 的能力。

// 把陣列轉成物件,鍵為陣列元素,值為指定的型別 V
function arrayToRecord<K extends string | number | symbol, V>(keys: K[], valueFactory: (k: K) => V): Record<K, V> {
  const result = {} as Record<K, V>;
  for (const k of keys) {
    result[k] = valueFactory(k);
  }
  return result;
}

// 使用範例
const colors = ["red", "green", "blue"] as const;
const colorMap = arrayToRecord(colors, c => `#${c}`);

 // colorMap 的型別為 Record<"red" | "green" | "blue", string>

透過 Record<K, V>,我們可以在編譯階段即得到 鍵集合 ("red" | "green" | "blue") 與 值型別 (string) 的完整資訊,避免手動寫 interface 時的繁瑣與錯誤。


4. 受限的泛型陣列:只接受特定子型別

有時候我們希望陣列只能接受某個介面的子型別,例如只允許 UserAdmin 這兩種型別。

interface BaseUser {
  id: number;
  name: string;
}
interface Admin extends BaseUser {
  adminLevel: number;
}
interface Guest extends BaseUser {
  expiresAt: Date;
}

// 只接受 BaseUser 的子型別
function filterById<T extends BaseUser>(list: T[], id: number): T | undefined {
  return list.find(item => item.id === id);
}

// 範例
const users: (Admin | Guest)[] = [
  { id: 1, name: "Alice", adminLevel: 3 },
  { id: 2, name: "Bob", expiresAt: new Date() },
];

const admin = filterById(users, 1); // 型別為 Admin | Guest | undefined

T extends BaseUser 限制了泛型只能是 BaseUser 或其子型別,讓函式在使用時仍能保留具體子型別的屬性(如 adminLevelexpiresAt)。


5. 深層泛型:遞迴型別搭配陣列與物件

在處理樹狀結構(例如目錄樹)時,常會同時出現「子節點是同樣型別的陣列」與「節點本身是一個物件」的情況。這時可以使用 遞迴泛型 來描述。

// 樹狀節點的型別
type TreeNode<T> = {
  value: T;
  children?: TreeNode<T>[];
};

// 建立一棵字串樹
const stringTree: TreeNode<string> = {
  value: "root",
  children: [
    { value: "child 1" },
    {
      value: "child 2",
      children: [{ value: "grandchild 1" }],
    },
  ],
};

// 泛型函式:遍歷樹並收集所有值
function collectValues<T>(node: TreeNode<T>, out: T[] = []): T[] {
  out.push(node.value);
  node.children?.forEach(child => collectValues(child, out));
  return out;
}

const allValues = collectValues(stringTree); // ["root","child 1","child 2","grandchild 1"]

TreeNode<T> 同時結合了 物件valuechildren)與 陣列children?: TreeNode<T>[]),而 collectValues 透過遞迴保持了泛型 T 的一致性,讓使用者在任意層級都能得到正確的型別推斷。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記限制泛型 若直接使用 function foo<T>(arg: T[]),呼叫者可以傳入 any[],失去型別安全。 使用 受限泛型 (T extends SomeInterface) 來限定允許的型別。
過度使用 any 在陣列與物件轉換時,常會寫 as any 以「暫時」解決錯誤,結果會在執行階段暴露 bug。 盡量使用 條件型別映射型別as const 讓編譯器自行推斷。
遺失 readonly 轉換後的陣列/物件如果應該是不可變的,卻忘記加上 readonly,導致意外變更。 在型別宣告時加入 readonly(如 readonly T[]Readonly<Record<...>>)。
遞迴型別深度過大 TypeScript 會對遞迴型別設定深度限制,過深的樹結構會產生 Type instantiation is excessively deep 錯誤。 使用 interface 替代 type,或在遞迴時加入 Partial/Pick 以減少展開層數。
忽略 undefined 在陣列搜尋或物件存取時,返回值可能是 undefined,但未在型別中顯示。 明確寫成 `T

最佳實踐

  1. 盡量讓編譯器自行推斷:不要手動指定泛型參數,除非有特殊需求。
  2. 使用 as const:讓字面量陣列/物件保持字面量型別,提升後續泛型推斷的精確度。
  3. 分層抽象:把通用的泛型工具(如 firstarrayToRecord)寫在共用模組,避免重複實作。
  4. 保持函式純粹:泛型函式若有副作用,會增加測試與除錯的難度。盡量返回新資料結構而非直接修改參數。

實際應用場景

場景 為何需要泛型與陣列/物件結合 範例概念
表單動態驗證 表單欄位集合是陣列,驗證規則是物件。使用泛型可以在編譯期保證每個欄位都有對應的驗證函式。 type FormSchema<T> = { [K in keyof T]: (value: T[K]) => boolean }
API 回傳資料映射 從後端取得的 JSON 陣列需要映射成前端模型,模型屬性可能因 API 版本不同而變化。 function mapResponse<T>(data: unknown[]): T[]
Redux/NgRx 狀態管理 狀態樹是巢狀物件,action 負載常是陣列或單一物件。泛型讓 reducer 能正確推斷 payload 型別。 interface Action<T, P> { type: T; payload: P }
資料視覺化圖表 圖表資料結構是 { series: T[]; labels: string[] },不同圖表類型接受不同的 T(數值、日期、字串)。 function renderChart<T>(config: ChartConfig<T>)
樹狀選單 (Tree View) 前端需要遞迴渲染樹狀選單,節點資料型別可能隨業務需求變化。 TreeNode<T>collectValues<T> 的組合

這些情境中,泛型不僅提升程式碼的可重用性,更能在編譯階段捕捉型別錯誤,減少跑到瀏覽器才發現的 bug。


總結

  • 泛型是 TypeScript 的核心特性,在處理陣列與物件時,能同時保留結構資訊與型別安全。
  • 透過 T[] / Array<T>Record<K,V>、條件型別遞迴型別 等技巧,我們可以寫出 通用且可預測 的工具函式。
  • 常見的陷阱包括忘記受限泛型、過度使用 any、遞迴深度過大等,遵循 受限泛型、保持純粹、使用 as const 等最佳實踐,可有效降低錯誤風險。
  • 表單驗證、API 映射、狀態管理、圖表渲染、樹狀結構 等實務場景中,泛型與陣列/物件的結合已成為提升開發效率與程式品質的關鍵工具。

掌握了本篇的概念與範例後,你就能在日常開發中自信地運用泛型,寫出 型別安全、可維護、易擴充 的 TypeScript 程式碼。祝你編程愉快!