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時自動被推斷為number或string,因此編譯器能正確提示firstNum或firstStr的型別。
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. 受限的泛型陣列:只接受特定子型別
有時候我們希望陣列只能接受某個介面的子型別,例如只允許 User 或 Admin 這兩種型別。
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 或其子型別,讓函式在使用時仍能保留具體子型別的屬性(如 adminLevel、expiresAt)。
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> 同時結合了 物件(value、children)與 陣列(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 |
最佳實踐
- 盡量讓編譯器自行推斷:不要手動指定泛型參數,除非有特殊需求。
- 使用
as const:讓字面量陣列/物件保持字面量型別,提升後續泛型推斷的精確度。 - 分層抽象:把通用的泛型工具(如
first、arrayToRecord)寫在共用模組,避免重複實作。 - 保持函式純粹:泛型函式若有副作用,會增加測試與除錯的難度。盡量返回新資料結構而非直接修改參數。
實際應用場景
| 場景 | 為何需要泛型與陣列/物件結合 | 範例概念 |
|---|---|---|
| 表單動態驗證 | 表單欄位集合是陣列,驗證規則是物件。使用泛型可以在編譯期保證每個欄位都有對應的驗證函式。 | 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 程式碼。祝你編程愉快!