TypeScript 進階型別操作 ── 遞迴型別(Recursive Types)
簡介
在日常開發中,我們常會碰到 樹狀結構、巢狀 JSON 或 目錄檔案系統 等需要自我參照的資料型別。普通的介面或型別定義只能描述一次層級,若要表示多層次、任意深度的結構,就需要 遞迴型別(Recursive Types)。
遞迴型別讓 TypeScript 能在編譯階段就檢查深層結構的正確性,避免在執行時才發現資料錯誤。掌握遞迴型別不只提升型別安全,也能讓程式碼更具可讀性與維護性,尤其在處理 樹狀演算法、圖形資料、樹形 UI 元件 時,幾乎是必備技巧。
本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,逐步帶你了解遞迴型別的威力,並提供可直接套用於專案的範例。
核心概念
1. 什麼是遞迴型別?
遞迴型別是指 型別本身在定義裡引用自己,形成「自我參照」的結構。最常見的寫法是使用 介面 (interface) 或 類別 (type alias),並在某個屬性上使用同名型別。
範例:一個簡單的樹節點
TreeNode<T>,每個節點可以有多個子節點,子節點的型別仍然是TreeNode<T>本身。
interface TreeNode<T> {
value: T;
children?: TreeNode<T>[];
}
此時 TreeNode<T> 內的 children 屬性是一個 陣列,陣列的元素型別仍是 TreeNode<T>,形成遞迴。
2. 使用 type Alias 時的注意
若直接使用 type 來定義遞迴型別,會因為 類型別名的自我參照 造成編譯錯誤。解法是 使用交叉類型 (&) 或 interface,或在 type 中加入 延遲 (lazy) 包裝(如 Partial、Pick)來打斷直接的自我參照。
type LinkedList<T> = {
value: T;
next?: LinkedList<T>; // ❌ 直接自我參照會錯誤
};
正確寫法:
type LinkedList<T> = {
value: T;
next?: LinkedListNode<T>;
};
interface LinkedListNode<T> {
value: T;
next?: LinkedListNode<T>;
}
或使用 遞迴條件型別(Conditional Types)搭配 分散 (Distributive) 特性,在需要時再遞迴。
3. 限制遞迴深度:Depth 參數
在某些情況下,我們不希望遞迴無限制地往下走,尤其是JSON 序列化或API 輸入驗證。可以透過 類別型別參數 來限制遞迴層級。
type JsonValue = string | number | boolean | null | JsonObject | JsonArray;
interface JsonObject {
[key: string]: JsonValue;
}
interface JsonArray extends Array<JsonValue> {}
type DeepPartial<T, Depth extends number = 5> = Depth extends 0
? T
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K], Decrement[Depth]>;
}
: T;
// 輔助類型:將數字遞減
type Decrement = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
DeepPartial 會在 Depth 為 0 時停止遞迴,避免產生過深的型別。
4. 透過 infer 進行遞迴拆解
TypeScript 4.1 以後支援 條件型別的 infer,可用於遞迴拆解結構。例如,將巢狀的 Promise 逐層「解包」成最終值。
type UnwrapPromise<T> = T extends Promise<infer U>
? UnwrapPromise<U>
: T;
// 使用範例
type Result = UnwrapPromise<Promise<Promise<string>>>; // Result 為 string
這裡的 UnwrapPromise 本身就是一個遞迴型別,透過 infer 把內層的 Promise 逐層抽出。
5. 互相遞迴:雙向遞迴型別
有時兩個型別會相互參照,例如 父子關係 或 圖形的節點與邊。可以使用 交叉類型 或 分離宣告 來避免循環引用錯誤。
interface Parent {
name: string;
children: Child[];
}
interface Child {
name: string;
parent: Parent;
}
此寫法在 TypeScript 中是合法的,因為介面允許互相引用而不會觸發「遞迴型別」錯誤。
程式碼範例
以下提供 5 個實務中常見的遞迴型別範例,並附上說明。
範例 1️⃣ 樹狀目錄 (File System)
interface FileNode {
/** 檔案或資料夾名稱 */
name: string;
/** 若是資料夾,children 會是一組 FileNode */
children?: FileNode[];
/** 檔案大小(單位:Byte),資料夾則為 undefined */
size?: number;
}
// 範例資料
const root: FileNode = {
name: "src",
children: [
{ name: "index.ts", size: 1240 },
{
name: "components",
children: [
{ name: "Button.tsx", size: 5320 },
{ name: "Modal.tsx", size: 7840 },
],
},
],
};
說明:
children使用FileNode[]形成遞迴,讓任意深度的檔案系統都能以同一型別描述。
範例 2️⃣ 鏈結串列 (Linked List)
interface ListNode<T> {
/** 節點值 */
value: T;
/** 指向下一個節點,最後一個節點的 next 為 undefined */
next?: ListNode<T>;
}
// 建立一條簡單的數字鏈結串列
const list: ListNode<number> = {
value: 1,
next: {
value: 2,
next: {
value: 3,
},
},
};
說明:透過
next?: ListNode<T>,型別本身在屬性上再次出現,形成遞迴結構,適合實作 資料結構、演算法。
範例 3️⃣ 深層 Partial (DeepPartial)
type Decrement = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type DeepPartial<T, Depth extends number = 5> = Depth extends 0
? T
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K], Decrement[Depth]>;
}
: T;
// 使用範例
interface Config {
api: {
url: string;
timeout: number;
};
theme: {
primary: string;
secondary: string;
};
}
type ConfigPatch = DeepPartial<Config, 2>;
/*
type ConfigPatch = {
api?: {
url?: string;
timeout?: number;
};
theme?: {
primary?: string;
secondary?: string;
};
}
*/
說明:
DeepPartial會在深度達到Depth時停止遞迴,常用於 表單更新、設定合併 等情境。
範例 4️⃣ 解包多層 Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
// 測試
type A = Awaited<Promise<Promise<number>>>; // A = number
type B = Awaited<string>; // B = string
說明:
Awaited透過條件型別遞迴地抽出最內層的值,類似 JavaScript 的await行為,但在編譯階段就能得知最終型別。
範例 5️⃣ 雙向遞迴:父子關係
interface Employee {
id: number;
name: string;
/** 直接下屬 */
subordinates?: Employee[];
/** 直屬主管 */
manager?: Employee;
}
// 範例資料
const alice: Employee = { id: 1, name: "Alice" };
const bob: Employee = { id: 2, name: "Bob", manager: alice };
alice.subordinates = [bob];
說明:
Employee同時引用自己作為subordinates與manager,形成 雙向遞迴,適合描述 組織圖、樹狀評論 等結構。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
直接使用 type 自己遞迴 |
type 別名不能直接自我參照,會產生 circular reference 錯誤。 |
改用 interface,或將遞迴部分拆成另一個 interface / type。 |
| 遞迴深度過大 | 型別遞迴過深會導致編譯速度變慢,甚至出現 Type instantiation is excessively deep and possibly infinite。 | 加入 深度限制(如 Depth 參數)或使用 any/unknown 作為遞迴的終止點。 |
| 錯誤的可選屬性 | 若遞迴屬性未標記為可選 (?) 或允許 null,會使型別變成必須無限遞迴。 |
為遞迴屬性加上 ? 或 ` |
過度使用 any |
為了逃避遞迴錯誤而直接使用 any,會失去型別安全的好處。 |
盡量使用 unknown 搭配 類型守衛,或使用 條件型別 逐層拆解。 |
| JSON.stringify 循環引用 | 雖然型別檢查通過,但實際資料若形成真實循環,JSON.stringify 會拋錯。 |
在資料結構中加入 parent?: null 或使用 WeakMap 追蹤已遍歷節點。 |
最佳實踐:
- 使用
interface來描述遞迴結構,因為介面天然支援自我參照。 - 將遞迴屬性設為可選或允許
null,確保遞迴能在任意層級終止。 - 加上深度上限(如
Depth參數)以防止編譯器陷入無限遞迴。 - 在公共函式庫中提供通用遞迴型別工具(如
DeepPartial、Awaited),讓團隊保持一致性。 - 寫測試:針對遞迴結構的型別斷言(type assertions)與 runtime 行為皆需要測試,避免隱藏的循環錯誤。
實際應用場景
| 場景 | 為何需要遞迴型別 | 示例 |
|---|---|---|
| 樹狀選單 / 多層下拉 | 選項可能有任意深度的子選項。 | MenuItem 介面配 children?: MenuItem[]。 |
| 文件系統或雲端儲存結構 | 檔案夾與檔案交替出現,層數不固定。 | 前述 FileNode 範例。 |
| 組織圖 / 員工階層 | 每位員工都有上司與下屬,關係雙向。 | Employee 雙向遞迴。 |
| JSON Schema / API 回傳驗證 | API 可能回傳深層巢狀的 JSON,需要在編譯時驗證。 | 使用 DeepPartial 或 RecursivePartial 產生驗證型別。 |
函式式程式設計的 Promise 解包 |
多層 Promise 需要在型別層面解開。 |
Awaited / UnwrapPromise。 |
| 圖形演算法(樹、圖) | 節點與邊的關係往往是遞迴或相互參照。 | TreeNode<T>、GraphNode<T>。 |
在這些情境下,遞迴型別不只是「寫起來好看」的語法糖,而是 保證資料結構正確性、減少 runtime 錯誤 的關鍵。
總結
遞迴型別是 TypeScript 進階型別操作中非常實用的工具,讓我們能在 編譯階段 把 任意深度的資料結構 明確描述出來。掌握以下要點,你就能在專案中自信地使用它:
- 使用
interface或 分離的type來避免直接的類別別名自我參照錯誤。 - 為遞迴屬性加上 可選 (
?) 或null,確保結構能在任意層級終止。 - 如有需要,加入 深度限制(
Depth參數)或 條件型別 來防止編譯器過度遞迴。 - 利用
infer與 條件型別,可以實作更高階的遞迴操作,例如 解包多層 Promise。 - 謹記常見陷阱,並遵守最佳實踐,才能在保持型別安全的同時,維持開發效率。
透過本文的概念說明、實作範例與最佳實踐,你現在已經具備在 React、Node.js、前端 UI 套件 或 任何需要處理巢狀資料 的專案中,安全且高效地使用遞迴型別的能力。祝你在 TypeScript 的旅程中,寫出更健壯、更可維護的程式碼! 🚀