本文 AI 產出,尚未審核

TypeScript 進階主題與最佳實踐 ── 型別運算與遞迴映射

簡介

在日常開發中,我們常常會發現 型別 只是一層「靜態」的保護,卻很少利用 TypeScript 本身所提供的強大型別運算能力。隨著專案規模的擴大,手動撰寫繁雜的型別定義會讓維護成本急速上升,甚至導致型別不一致的錯誤悄悄潛入程式碼。

本單元聚焦於 型別運算(type operators) 與 遞迴映射(recursive mapped types),說明它們如何讓我們在 編譯階段 完成資料結構的轉換、深層驗證與自動化衍生。掌握這些技巧後,你將能寫出更安全、可維護、且彈性十足的 TypeScript 程式。


核心概念

1. 基本型別運算子

TypeScript 提供了四大基本型別運算子:

運算子 說明 範例
keyof 取得物件型別的所有鍵(key)聯集 `type K = keyof { a: 1; b: 2 } // "a"
typeof 取得變數或物件的型別 const foo = { x: 10 }; type T = typeof foo; // { x: number }
infer 在條件型別裡推斷子型別 type Return<T> = T extends (...args: any[]) => infer R ? R : never;
extends(條件型別) 根據型別關係分支 type IsString<T> = T extends string ? true : false;

小技巧:keyof any 會得到 string | number | symbol,可用來建立「任意鍵」的通用型別。


2. 映射型別(Mapped Types)

映射型別允許我們對 每一個屬性鍵 產生新型別,語法如下:

type Mapped<T> = {
  [P in keyof T]: /* 新的屬性型別 */
};

2.1 常見的映射範例

// 1️⃣ 讓所有屬性變成只讀
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type User = { id: number; name: string };
type ReadonlyUser = Readonly<User>;
// => { readonly id: number; readonly name: string }
// 2️⃣ 將所有屬性設為可選
type Partial<T> = {
  [P in keyof T]?: T[P];
};

type PartialUser = Partial<User>;
// => { id?: number; name?: string }
// 3️⃣ 把屬性型別全部改成字串
type ToString<T> = {
  [P in keyof T]: string;
};

type StringUser = ToString<User>;
// => { id: string; name: string }

3. 交叉與合併型別(Intersection & Union)

type A = { a: number };
type B = { b: string };
type AB = A & B;          // 交叉型別:{ a: number; b: string }
type C = { a: number } | { a: string }; // 合併型別(聯集)

實務上,交叉型別常與映射型別結合,用來 擴充 或 改寫 原有結構。


4. 遞迴映射(Recursive Mapped Types)

當我們需要 深層 轉換物件結構(例如把所有屬性改成只讀或可選),單一層的映射不足以完成,這時就需要 遞迴。

4.1 深度只讀(DeepReadonly)

type DeepReadonly<T> = T extends Function
  ? T
  : T extends object
  ? { readonly [P in keyof T]: DeepReadonly<T[P]> }
  : T;

// 測試
type Nested = {
  id: number;
  meta: {
    created: Date;
    tags: string[];
  };
};

type ReadonlyNested = DeepReadonly<Nested>;
/*
{
  readonly id: number;
  readonly meta: {
    readonly created: Date;
    readonly tags: readonly string[];
  };
}
*/

說明:

  1. 先排除 Function,因為函式本身不需要遞迴。
  2. 若是 object,則對每個屬性再次套用 DeepReadonly。
  3. 其餘(原始值)直接回傳。

4.2 深度可選(DeepPartial)

type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

// 範例
type Config = {
  port: number;
  logger: {
    level: 'debug' | 'info' | 'error';
    file?: string;
  };
};

type PartialConfig = DeepPartial<Config>;
/*
{
  port?: number;
  logger?: {
    level?: 'debug' | 'info' | 'error';
    file?: string;
  };
}
*/

4.3 透過條件型別過濾鍵(Key Filtering)

有時候只想 保留特定屬性,例如只保留 string 型別的欄位:

type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never
}[keyof T];

type OnlyStringProps<T> = Pick<T, StringKeys<T>>;

// 範例
type Mixed = { id: number; name: string; flag: boolean };
type StringOnly = OnlyStringProps<Mixed>; // { name: string }

StringKeys<T> 先產生一個映射型別,將不符合條件的鍵映射為 never,最後再透過索引存取 ([keyof T]) 把 never 去除,得到真正的鍵集合。


常見陷阱與最佳實踐

陷阱 說明 解法
遞迴深度過深 TypeScript 會在遞迴深度超過預設上限(約 50)時報錯 Type alias '...' circularly references itself。 使用 type 的 分段遞迴(將深層結構拆成多個小型型別)或利用 any 暫時斷開遞迴。
any 侵蝕型別安全 在映射或條件型別中混入 any,會讓整個結果退化為 any。 儘量使用 unknown,或在需要時明確 斷言 (as)。
函式屬性被誤改 DeepReadonly 會把函式本身設為只讀,若要保留可呼叫性,需要排除 Function。 如上範例,先 T extends Function ? T : ...。
索引簽名遺失 Pick<T, K> 只會保留明確列出的鍵,會遺失 [key: string]: any 之類的索引簽名。 使用 & 合併原始索引簽名:type WithIndex<T> = T & { [key: string]: unknown }。
過度使用條件型別 條件型別過於複雜會讓 IDE 補完變慢,且錯誤訊息難以閱讀。 盡量 拆解 成小型、可重用的型別,並加上說明性的 type 名稱。

最佳實踐:

  1. 保持型別可讀:為每個遞迴映射寫上簡短註解,必要時使用 /** @description */。
  2. 模組化:將常用的遞迴型別(DeepReadonly、DeepPartial)抽成獨立檔案,作為公共庫。
  3. 測試型別:利用 type-tests(例如 type-challenges)或 tsd 套件驗證型別行為。
  4. 限制遞迴深度:在大型結構上,考慮只遞迴到 兩層(PartialDeep2)即可,減少編譯負擔。

實際應用場景

1. API 回傳型別的自動轉換

假設後端回傳的 JSON 物件全部為 可選,前端想把它轉成 必填,同時保留深層結構:

type ApiResponse<T> = DeepPartial<T>; // 從 API 取得的型別
type ToRequired<T> = {
  [P in keyof T]-?: ToRequired<T[P]>;
};

type UserFromApi = ApiResponse<User>;
type UserFull = ToRequired<UserFromApi>;

這樣的型別映射讓 資料驗證 與 表單預設值 的填入變得自動化。

2. Redux 狀態的不可變更新

在 Redux 中,我們常需要 深度只讀 的狀態型別,防止 reducer 直接修改:

type RootState = {
  auth: {
    user: User;
    token: string;
  };
  settings: {
    theme: 'light' | 'dark';
    language: string;
  };
};

type ImmutableState = DeepReadonly<RootState>;

const reducer = (state: ImmutableState, action: Action): ImmutableState => {
  // TypeScript 會在此阻止任何 mutable 操作
  // ...
  return state;
};

3. 表單驗證庫(如 Zod / Yup)自動產生型別

使用 Zod 定義 schema 時,我們可以透過條件型別自動推導 深層可選 或 必填 的 TypeScript 型別:

import { z } from 'zod';

const userSchema = z.object({
  id: z.number(),
  profile: z.object({
    name: z.string(),
    age: z.number().optional(),
  }),
});

type UserSchema = z.infer<typeof userSchema>; // { id: number; profile: { name: string; age?: number } }
type UserPartial = DeepPartial<UserSchema>;   // 全部屬性變為可選

總結

  • 型別運算子(keyof、typeof、infer、條件型別)是 TypeScript 靜態分析的基礎工具。
  • 映射型別 讓我們能對每個屬性鍵批次產生新型別,配合 交叉、合併 可靈活改寫結構。
  • 遞迴映射(如 DeepReadonly、DeepPartial)則提供了 深層 轉換的能力,解決複雜物件在不可變、可選或型別過濾上的需求。
  • 在實務開發中,適當運用這些技巧能顯著提升 型別安全、程式可維護性 與 開發效率,尤其在 API 整合、狀態管理與表單驗證等場景更是不可或缺。

最後提醒:雖然型別運算與遞迴映射能讓程式碼更安全,但過度複雜的型別也會降低可讀性與編譯效能。保持 簡潔、可測試,並遵循前述 最佳實踐,才能在大型專案中真正發揮 TypeScript 的威力。祝你寫出更「型」的程式碼 🎉