本文 AI 產出,尚未審核

TypeScript 進階型別操作 – Mapped Types

簡介

在大型前端或後端專案中,型別的可維護性往往是成功的關鍵。隨著需求變化,我們常常需要根據既有型別自動產生新型別,例如把所有屬性改成只讀、或把屬性名稱加上前綴。傳統的手動寫法不僅冗長,還容易遺漏或產生錯誤。
TypeScript 在 2.1 版開始引入 Mapped Types(映射型別),讓開發者可以在編譯期以「映射」的方式批次轉換屬性。透過 Mapped Types,我們可以寫出更簡潔、可重用且安全的型別工具,極大提升程式碼的可讀性與維護性。

本文將深入探討 Mapped Types 的語法與概念,提供多個實務範例,並說明常見陷阱與最佳實踐,讓您能在日常開發中立即上手。


核心概念

1. 基本語法

Mapped Types 的基本形態是:

type NewType = {
  [P in keyof OldType]: 轉換型別;
};
  • keyof OldType 取得舊型別的所有屬性鍵(key)集合。
  • P in keyof OldType 代表「對每一個鍵 P」進行映射。
  • 轉換型別 可以是原屬性的型別、Readonly<T>Partial<T>,或任何自訂的型別運算。

重點:映射的結果會保留原本屬性的可選性(?)與 readonly 修飾,除非在映射時明確改寫。


2. 內建映射型別:Partial, Required, Readonly, Pick, Record

TypeScript 已提供多個常用的映射型別,我們可以直接使用或作為自訂的範本。

interface User {
  id: number;
  name: string;
  email?: string;
}

// 把所有屬性變成可選
type UserPartial = Partial<User>;

// 把所有屬性變成必填
type UserRequired = Required<User>;

// 把所有屬性設為 readonly
type UserReadonly = Readonly<User>;

// 只挑選部分屬性
type UserNameOnly = Pick<User, "name">;

// 建立鍵值對映射 (Record)
type RoleMap = Record<"admin" | "guest", User>;

3. 自訂映射型別 – 基礎範例

3.1 只讀映射 (MyReadonly)

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

interface Post {
  title: string;
  content: string;
}

type ReadonlyPost = MyReadonly<Post>;

// 使用範例
const p: ReadonlyPost = { title: "Hello", content: "World" };
// p.title = "Hi"; // ❌ 編譯錯誤:Cannot assign to 'title' because it is a read-only property.

3.2 可選映射 (MyPartial)

type MyPartial<T> = {
  [P in keyof T]?: T[P];
};

type PartialPost = MyPartial<Post>;

const draft: PartialPost = { title: "Draft" }; // 只提供部分屬性合法

3.3 轉換屬性型別 (MapToString)

type MapToString<T> = {
  [P in keyof T]: string;
};

type StringifiedPost = MapToString<Post>;

const s: StringifiedPost = {
  title: "123",
  content: "456"
}; // 所有屬性都被強制為 string

4. 進階映射 – 結合條件型別

4.1 把函式屬性改為非必填 (OptionalMethods)

type OptionalMethods<T> = {
  [P in keyof T]: T[P] extends (...args: any[]) => any ? T[P]? : T[P];
};

interface Service {
  fetch(): Promise<string>;
  url: string;
}

type ServiceOptional = OptionalMethods<Service>;

const s1: ServiceOptional = {
  url: "https://api.example.com"
  // fetch? 可有可無
};

4.2 依屬性名稱加前綴 (PrefixKeys)

type PrefixKeys<T, Prefix extends string> = {
  [P in keyof T as `${Prefix}${Capitalize<string & P>}`]: T[P];
};

interface Config {
  timeout: number;
  retry: number;
}

type PrefixedConfig = PrefixKeys<Config, "api">;

/*
type PrefixedConfig = {
  apiTimeout: number;
  apiRetry: number;
}
*/

技巧:使用 as 重新映射鍵名,可結合模板字串 (${}) 與內建工具類型 CapitalizeUncapitalize 產生更具語意的鍵。


5. 多層映射 – 產生深層只讀 (DeepReadonly)

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

interface Nested {
  user: {
    id: number;
    profile: {
      name: string;
      age: number;
    };
  };
  tags: string[];
}

type ImmutableNested = DeepReadonly<Nested>;

const n: ImmutableNested = {
  user: {
    id: 1,
    profile: { name: "Alice", age: 30 }
  },
  tags: ["ts", "js"]
};

// n.user.profile.age = 31; // ❌ 錯誤

此範例展示 遞迴映射,可把任意深度的物件全部凍結,對於 Redux、Immutable.js 等需要不可變資料結構的情境非常實用。


常見陷阱與最佳實踐

陷阱 說明 解決方式
映射過度寬鬆 若使用 anyunknown 作為來源,映射結果可能失去型別檢查。 盡量在映射前使用 具體的介面或型別,或加入條件型別限制。
遞迴深度過大 DeepReadonly 等遞迴映射在極深層結構上會觸發編譯器的 type instantiation depth 限制。 使用 tsconfig.jsontypeRootsmaxNodeModuleJsDepth 調整,或手動分段遞迴。
鍵名重新映射失效 as 重新映射鍵名時,若使用錯誤的類型斷言(如 string & P),會導致鍵名被丟棄。 確保使用 交叉類型 (string & P) 或 模板字串 正確拼接。
可選屬性與必填屬性混用 映射時忘記保留 ?,會把原本可選屬性變成必填,造成使用者錯誤。 在映射時加入 ?:-?(移除可選性) 明確控制。
函式屬性遺失 某些映射型別(如 Partial<T>)會把函式屬性也變成可選,導致呼叫時需要額外檢查。 使用 條件型別 只針對資料屬性做映射,保留函式原樣。

最佳實踐

  1. 先定義基礎介面,再以映射產生變體,避免重複寫相同屬性。
  2. 利用條件型別 把映射範圍限制在需要的屬性上(例如只處理屬性值是 string 的鍵)。
  3. 保持可讀性:為自訂映射型別加上說明性註解或 /** */,讓團隊成員快速了解意圖。
  4. 測試型別:使用 type 測試(如 type Assert<T extends true> = T;)確保映射結果符合預期。

實際應用場景

  1. API 回傳型別轉換
    從後端取得的 JSON 常常屬性名稱與前端使用的命名規則不同。透過 PrefixKeysMapToString,可以在同一層級自動產生「前端專用」的型別,減少手動映射的錯誤。

  2. 表單驗證與 UI 狀態
    使用 Partial<T> 為表單資料建立「暫存」型別,配合 Required<T> 在送出前檢查所有欄位是否完整。Readonly<T> 可以保護已提交的資料不被意外修改。

  3. Redux / NgRx 狀態管理
    DeepReadonly 能確保全局狀態樹在每次 reducer 執行後保持不可變,避免不小心直接改寫 state。

  4. 自動產生 DTO(Data Transfer Object)
    企業級系統常需要在不同層之間傳遞資料結構。使用映射型別可以在 TypeScript 中一次定義原始模型,然後生成「僅包含必要欄位」的 DTO,提升效能與安全性。

  5. 多語系字串資源
    假設有一組介面描述 UI 文本 interface Labels { title: string; ok: string; cancel: string; },可透過映射產生 type TranslatedLabels = { [K in keyof Labels]: { zh: string; en: string; } },一次得到所有語系的型別結構。


總結

Mapped Types 是 TypeScript 中最具威力的 型別工具 之一,讓開發者能夠以宣告式的方式批次轉換屬性,減少重複程式碼、提升型別安全。本文介紹了:

  • 基本映射語法與內建型別 (Partial, Readonly 等)
  • 如何自行實作 MyReadonlyMyPartialPrefixKeys 等實用範例
  • 結合條件型別與遞迴映射打造深層只讀結構
  • 常見陷阱、最佳實踐以及在 API、表單、狀態管理、DTO 生成等情境下的實務應用

掌握這些概念後,您將能在大型專案中快速建立一致且可維護的型別系統,讓 TypeScript 真正發揮「在編譯期捕捉錯誤」的價值。祝您寫程式愉快,型別永遠保護您!