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重新映射鍵名,可結合模板字串 (${}) 與內建工具類型Capitalize、Uncapitalize產生更具語意的鍵。
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 等需要不可變資料結構的情境非常實用。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 映射過度寬鬆 | 若使用 any 或 unknown 作為來源,映射結果可能失去型別檢查。 |
盡量在映射前使用 具體的介面或型別,或加入條件型別限制。 |
| 遞迴深度過大 | DeepReadonly 等遞迴映射在極深層結構上會觸發編譯器的 type instantiation depth 限制。 |
使用 tsconfig.json 的 typeRoots 或 maxNodeModuleJsDepth 調整,或手動分段遞迴。 |
| 鍵名重新映射失效 | 在 as 重新映射鍵名時,若使用錯誤的類型斷言(如 string & P),會導致鍵名被丟棄。 |
確保使用 交叉類型 (string & P) 或 模板字串 正確拼接。 |
| 可選屬性與必填屬性混用 | 映射時忘記保留 ?,會把原本可選屬性變成必填,造成使用者錯誤。 |
在映射時加入 ?: 或 -?(移除可選性) 明確控制。 |
| 函式屬性遺失 | 某些映射型別(如 Partial<T>)會把函式屬性也變成可選,導致呼叫時需要額外檢查。 |
使用 條件型別 只針對資料屬性做映射,保留函式原樣。 |
最佳實踐
- 先定義基礎介面,再以映射產生變體,避免重複寫相同屬性。
- 利用條件型別 把映射範圍限制在需要的屬性上(例如只處理屬性值是
string的鍵)。 - 保持可讀性:為自訂映射型別加上說明性註解或
/** */,讓團隊成員快速了解意圖。 - 測試型別:使用
type測試(如type Assert<T extends true> = T;)確保映射結果符合預期。
實際應用場景
API 回傳型別轉換
從後端取得的 JSON 常常屬性名稱與前端使用的命名規則不同。透過PrefixKeys或MapToString,可以在同一層級自動產生「前端專用」的型別,減少手動映射的錯誤。表單驗證與 UI 狀態
使用Partial<T>為表單資料建立「暫存」型別,配合Required<T>在送出前檢查所有欄位是否完整。Readonly<T>可以保護已提交的資料不被意外修改。Redux / NgRx 狀態管理
DeepReadonly能確保全局狀態樹在每次 reducer 執行後保持不可變,避免不小心直接改寫 state。自動產生 DTO(Data Transfer Object)
企業級系統常需要在不同層之間傳遞資料結構。使用映射型別可以在 TypeScript 中一次定義原始模型,然後生成「僅包含必要欄位」的 DTO,提升效能與安全性。多語系字串資源
假設有一組介面描述 UI 文本interface Labels { title: string; ok: string; cancel: string; },可透過映射產生type TranslatedLabels = { [K in keyof Labels]: { zh: string; en: string; } },一次得到所有語系的型別結構。
總結
Mapped Types 是 TypeScript 中最具威力的 型別工具 之一,讓開發者能夠以宣告式的方式批次轉換屬性,減少重複程式碼、提升型別安全。本文介紹了:
- 基本映射語法與內建型別 (
Partial,Readonly等) - 如何自行實作
MyReadonly、MyPartial、PrefixKeys等實用範例 - 結合條件型別與遞迴映射打造深層只讀結構
- 常見陷阱、最佳實踐以及在 API、表單、狀態管理、DTO 生成等情境下的實務應用
掌握這些概念後,您將能在大型專案中快速建立一致且可維護的型別系統,讓 TypeScript 真正發揮「在編譯期捕捉錯誤」的價值。祝您寫程式愉快,型別永遠保護您!