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[];
};
}
*/
說明:
- 先排除
Function,因為函式本身不需要遞迴。- 若是
object,則對每個屬性再次套用DeepReadonly。- 其餘(原始值)直接回傳。
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 名稱。 |
最佳實踐:
- 保持型別可讀:為每個遞迴映射寫上簡短註解,必要時使用
/** @description */。 - 模組化:將常用的遞迴型別(
DeepReadonly、DeepPartial)抽成獨立檔案,作為公共庫。 - 測試型別:利用
type-tests(例如type-challenges)或tsd套件驗證型別行為。 - 限制遞迴深度:在大型結構上,考慮只遞迴到 兩層(
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 的威力。祝你寫出更「型」的程式碼 🎉