本文 AI 產出,尚未審核

TypeScript 進階型別操作 — 型別映射與再映射(Remapped Keys)


簡介

在日常開發中,我們常會遇到 「把物件的屬性名稱」 依某種規則轉換成新形態的需求,例如把 API 回傳的 snake_case 鍵改成 camelCase,或是根據字典把英文欄位映射成中文顯示名稱。
傳統的手寫函式雖然能達成目的,但在大型專案裡會造成 型別資訊遺失維護成本升高,而且錯誤只能在執行時才被捕捉。

TypeScript 4.1 之後引入的 型別映射(Mapped Types) 搭配 再映射(Remapped Keys),讓我們可以在 編譯階段 完成鍵名的轉換,同時保留完整的型別安全。這項功能不僅讓程式碼更精簡,也讓 IDE 能提供更好的自動完成與錯誤提示,對於 API 資料轉換、表單驗證、國際化 等情境尤為重要。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後列出實務應用場景,帶領讀者一步一步掌握 Remapped Keys 的威力。


核心概念

1️⃣ 什麼是型別映射(Mapped Types)?

型別映射是指以 keyof 取得某個型別的所有屬性鍵,然後用 [K in keyof T] 產生新型別的語法。例如:

type ReadonlyUser = {
  [K in keyof User]: Readonly<User[K]>;
};

上述程式碼會把 User 的每個屬性都變成 readonly,而不需要手動列舉每個欄位。

2️⃣ 再映射(Remapped Keys)是什麼?

再映射允許我們在映射的同時 改變鍵名,語法是:

type NewType = {
  [K in keyof OldType as NewKey]: ...
};
  • as 後面的 NewKey 可以是任意的 字串/數字型別,甚至是條件運算式 (K extends ... ? ... : ...)。
  • 這讓我們可以 同時改變鍵名與值的型別,而不必再寫兩段程式碼。

⚡ 重點:再映射的鍵名必須仍然是 字串或數字,否則會出現 Type 'symbol' cannot be used as an index type 的錯誤。

3️⃣ 基本語法範例

type SnakeToCamel<T> = {
  [K in keyof T as K extends `${infer P}_${infer Q}`
    ? `${Capitalize<P>}${Capitalize<Q>}`
    : K]: T[K];
};
  • K extends \${infer P}_${infer Q}`用 **模板字面型別** 把snake_case` 拆成兩段。
  • Capitalize<> 把每段的首字母大寫,最後組合成 camelCase(簡化版)。

程式碼範例

以下提供 5 個實用範例,從最基礎到稍微進階,幫助你快速上手。

範例 1️⃣ 轉換鍵名的最簡版:snake_casecamelCase

type SnakeToCamel<T> = {
  [K in keyof T as K extends `${infer A}_${infer B}`
    ? `${A}${Capitalize<B>}`
    : K]: T[K];
};

type ApiResponse = {
  user_id: number;
  user_name: string;
  created_at: string;
};

type CamelResponse = SnakeToCamel<ApiResponse>;
/*
type CamelResponse = {
  userId: number;
  userName: string;
  createdAt: string;
}
*/

說明:使用模板字面型別 (${infer A}_${infer B}) 把底線分割,Capitalize 只對第二段做首字母大寫,產生 camelCase 鍵名。


範例 2️⃣ 同時改變值的型別:字串 → 數字

假設 API 回傳的所有屬性都是字串,我想在型別層面把可解析為 number 的欄位轉成 number

type ParseNumbers<T> = {
  [K in keyof T as K]: T[K] extends `${number}`
    ? number
    : T[K];
};

type RawData = {
  id: "123";
  name: "Alice";
  age: "30";
};

type ParsedData = ParseNumbers<RawData>;
/*
type ParsedData = {
  id: number;   // 由字串變成 number
  name: string;
  age: number;
}
*/

技巧:條件型別 (T[K] extends \${number}``) 讓我們只針對符合格式的屬性改型別,其他保持不變。


範例 3️⃣ 把屬性名稱映射成 中文顯示名稱,同時保留原始值型別

type TranslateKeys<T, M extends Record<keyof T, string>> = {
  [K in keyof T as M[K]]: T[K];
};

type User = {
  firstName: string;
  lastName: string;
  birthDate: string;
};

type ChineseMap = {
  firstName: "名字";
  lastName: "姓氏";
  birthDate: "出生日期";
};

type UserCN = TranslateKeys<User, ChineseMap>;
/*
type UserCN = {
  名字: string;
  姓氏: string;
  出生日期: string;
}
*/

實務意義:在多語系 UI 中,直接把資料型別映射成顯示語言,避免在 UI 層手動寫 as const 之類的字典。


範例 4️⃣ 產生 只讀且鍵名改寫 的型別

type DeepReadonlyRemap<T> = {
  readonly [K in keyof T as `readonly_${string & K}`]: T[K] extends object
    ? DeepReadonlyRemap<T[K]>
    : T[K];
};

type Nested = {
  config: {
    url: string;
    timeout: number;
  };
  enabled: boolean;
};

type ReadonlyNested = DeepReadonlyRemap<Nested>;
/*
type ReadonlyNested = {
  readonly_readonly_config: {
    readonly_url: string;
    readonly_timeout: number;
  };
  readonly_enabled: boolean;
}
*/

說明:利用遞迴型別把所有子層也套用同樣的 再映射 + 只讀 規則,適合生成不可變的設定物件。


範例 5️⃣ 以 Union 為基礎的鍵名過濾與映射

type FilterAndRemap<T, U extends keyof T> = {
  [K in keyof T as K extends U ? `opt_${K}` : never]: T[K];
};

type Options = {
  debug: boolean;
  verbose: boolean;
  timeout: number;
  mode: "auto" | "manual";
};

type DebugOptions = FilterAndRemap<Options, "debug" | "verbose">;
/*
type DebugOptions = {
  opt_debug: boolean;
  opt_verbose: boolean;
}
*/

重點:使用 never 讓不符合條件的鍵被剔除,同時把保留下來的鍵加上前綴 opt_


常見陷阱與最佳實踐

陷阱 說明 解決方案
鍵名變成 never 再映射時若 as 表達式結果為 never,該鍵會被自動剔除,可能不小心把全部屬性都過濾掉。 在條件式最後加上 : K 或使用 never 前先檢查 K extends … ? … : K
模板字面型別錯誤 使用 ${infer A}_${infer B} 時,若原鍵不符合模板會返回 never,導致鍵名遺失。 使用 K extends \${infer A}_${infer B}` ? … : K` 來保留不符合的鍵。
遞迴深度過深 深層物件遞迴映射可能觸發 TypeScript 的遞迴深度限制 (--maxDepth). 盡量限制遞迴層數或拆成多個中間型別;在 tsconfig.json 中調整 typeRoots
Symbol 鍵無法再映射 symbol 型別的屬性無法用 as 產生新鍵。 若必須保留 symbol,在映射前先排除 symbol[K in keyof T as K extends symbol ? never : …]
映射後失去原始聯合 再映射會把聯合型別的每個成員分別映射,結果可能變成交叉型別。 使用 Extract<…>Exclude<…> 重新組合聯合。

最佳實踐

  1. 先寫測試:因為型別錯誤只在編譯期顯示,寫一個簡單的 type Expect<T extends true> = T; 斷言可以避免不小心的鍵名遺失。
  2. 保持可讀性:過度複雜的模板字面型別會讓型別定義難以維護,建議把複雜邏輯抽成 輔助型別(如 SnakeToCamelKey<T>)。
  3. 使用 as const:在字典型別(如中文映射)上加上 as const,讓 TypeScript 推斷出字面值而非寬鬆的 string
  4. 文件化:在大型專案中,將映射型別與說明放在 types/ 目錄,並在 README 中說明使用方式,避免新加入的開發者產生誤解。

實際應用場景

場景 為什麼需要 Remapped Keys 範例型別
API 資料正規化 後端常使用 snake_case,前端慣用 camelCase。 type Normalized<T> = SnakeToCamel<T>
多語系 UI 把後端欄位映射成使用者語系的顯示名稱。 type Localized<T, M> = TranslateKeys<T, M>
表單驗證 把原始資料的鍵名映射成驗證規則的鍵名(如 required_* 前綴)。 type ValidationSchema<T> = FilterAndRemap<T, RequiredKeys>
Immutable 設定 產生只讀且鍵名帶前綴的設定物件,防止意外寫入。 type Config<T> = DeepReadonlyRemap<T>
插件系統 把插件提供的選項鍵名統一加上命名空間前綴。 type Namespaced<T, NS extends string> = { [K in keyof T as \${NS}_${K}`]: T[K] }`

實務小技巧:在 Redux 或 Zustand 等狀態管理工具中,常會把 slice 的狀態鍵名加上前綴,避免不同 slice 的鍵衝突。使用再映射可以一次完成「加前綴 + 只讀」的工作,大幅降低手寫錯誤的機會。


總結

  • 型別映射 讓我們能在編譯期遍歷物件屬性,再映射(Remapped Keys) 則進一步提供 鍵名變換 的能力。
  • 透過 模板字面型別、條件型別與遞迴,我們可以建立 snake_case → camelCase英文 → 中文加前綴/過濾 等多樣化的型別工具。
  • 常見的陷阱包括 鍵名變成 never、模板不匹配、遞迴深度限制,只要遵循 先測試、抽離輔助型別、使用 as const 等最佳實踐,就能寫出既安全又易維護的程式碼。
  • API 正規化、國際化 UI、表單驗證、Immutable 設定、插件命名空間 等實務情境中,Remapped Keys 能顯著減少重複代碼、提升型別安全,讓開發團隊更專注於業務邏輯本身。

把這些技巧加入你的 TypeScript 工具箱,未來面對任何需要「變換鍵名」的需求,都能快速、正確地完成,讓程式碼更乾淨、可讀、可靠。祝你玩得開心,寫出更好的型別安全程式!