本文 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. 模組化:將常用的遞迴型別(DeepReadonlyDeepPartial)抽成獨立檔案,作為公共庫。
  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>;   // 全部屬性變為可選

總結

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

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