本文 AI 產出,尚未審核

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) 包裝(如 PartialPick)來打斷直接的自我參照。

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 同時引用自己作為 subordinatesmanager,形成 雙向遞迴,適合描述 組織圖樹狀評論 等結構。


常見陷阱與最佳實踐

陷阱 說明 解決方案
直接使用 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 追蹤已遍歷節點。

最佳實踐

  1. 使用 interface 來描述遞迴結構,因為介面天然支援自我參照。
  2. 將遞迴屬性設為可選或允許 null,確保遞迴能在任意層級終止。
  3. 加上深度上限(如 Depth 參數)以防止編譯器陷入無限遞迴。
  4. 在公共函式庫中提供通用遞迴型別工具(如 DeepPartialAwaited),讓團隊保持一致性。
  5. 寫測試:針對遞迴結構的型別斷言(type assertions)與 runtime 行為皆需要測試,避免隱藏的循環錯誤。

實際應用場景

場景 為何需要遞迴型別 示例
樹狀選單 / 多層下拉 選項可能有任意深度的子選項。 MenuItem 介面配 children?: MenuItem[]
文件系統或雲端儲存結構 檔案夾與檔案交替出現,層數不固定。 前述 FileNode 範例。
組織圖 / 員工階層 每位員工都有上司與下屬,關係雙向。 Employee 雙向遞迴。
JSON Schema / API 回傳驗證 API 可能回傳深層巢狀的 JSON,需要在編譯時驗證。 使用 DeepPartialRecursivePartial 產生驗證型別。
函式式程式設計的 Promise 解包 多層 Promise 需要在型別層面解開。 Awaited / UnwrapPromise
圖形演算法(樹、圖) 節點與邊的關係往往是遞迴或相互參照。 TreeNode<T>GraphNode<T>

在這些情境下,遞迴型別不只是「寫起來好看」的語法糖,而是 保證資料結構正確性減少 runtime 錯誤 的關鍵。


總結

遞迴型別是 TypeScript 進階型別操作中非常實用的工具,讓我們能在 編譯階段任意深度的資料結構 明確描述出來。掌握以下要點,你就能在專案中自信地使用它:

  • 使用 interface分離的 type 來避免直接的類別別名自我參照錯誤。
  • 為遞迴屬性加上 可選 (?) 或 null,確保結構能在任意層級終止。
  • 如有需要,加入 深度限制Depth 參數)或 條件型別 來防止編譯器過度遞迴。
  • 利用 infer條件型別,可以實作更高階的遞迴操作,例如 解包多層 Promise
  • 謹記常見陷阱,並遵守最佳實踐,才能在保持型別安全的同時,維持開發效率。

透過本文的概念說明、實作範例與最佳實踐,你現在已經具備在 React、Node.js、前端 UI 套件任何需要處理巢狀資料 的專案中,安全且高效地使用遞迴型別的能力。祝你在 TypeScript 的旅程中,寫出更健壯、更可維護的程式碼! 🚀