本文 AI 產出,尚未審核
TypeScript
單元:工具型別(Utility Types)
主題:自訂工具型別(Custom Utility Types)
簡介
在大型前端或 Node.js 專案中,型別安全是維持程式碼品質的關鍵。
TypeScript 已內建多種 工具型別(如 Partial<T>、Pick<T, K>)讓我們能夠快速地對既有型別做變形,但這些內建型別往往只能解決一般化的需求。當業務邏輯變得更複雜、資料結構更深層,或是需要特定的驗證規則時,我們就需要 自訂工具型別 來彈性擴充。
自訂工具型別不僅能減少重複的型別宣告,還能在編譯階段即捕捉錯誤,提升開發效率與可維護性。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握如何在 TypeScript 中打造自己的工具型別。
核心概念
1. 為什麼要自訂工具型別?
- 業務需求多樣:例如「某些欄位必填、其他欄位可選」或「深層物件的全部屬性改為可選」等,內建工具型別無法一次滿足。
- 避免重複程式碼:把常見的型別變形抽成工具型別,可在多個檔案間共享,同時保持型別一致性。
- 提升編譯期錯誤偵測:自訂工具型別利用條件型別(Conditional Types)與映射型別(Mapped Types)在編譯階段即驗證資料結構,降低執行時錯誤。
Tip:在實作前,先確定是否真的需要自訂工具型別;若內建型別已能滿足需求,直接使用即可,避免過度抽象。
2. 基礎語法回顧
| 關鍵字 | 說明 |
|---|---|
type |
宣告型別別名,可用於建立工具型別 |
keyof |
取得型別的所有屬性鍵 |
in |
用於映射型別的迭代 |
extends |
條件型別的判斷基礎 |
infer |
從型別推斷出子型別(常與條件型別搭配) |
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
3. 實用範例
以下示範 5 個常見且實務導向的自訂工具型別,皆以 完整註解 說明其運作原理。
3.1. MyPick – 自訂版 Pick
/**
* 只挑選物件 T 中指定的屬性 K,等同於內建的 Pick<T, K>。
* 使用條件型別確保 K 必須是 T 的鍵。
*/
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
/* 範例 */
type User = { id: number; name: string; age: number; email: string };
type UserNameAndEmail = MyPick<User, "name" | "email">;
/* -> { name: string; email: string } */
3.2. DeepPartial – 深層 Partial
/**
* 讓物件的所有屬性(包括巢狀物件)皆變為可選。
* 透過遞迴映射型別實作,若屬性本身也是物件則再次套用 DeepPartial。
*/
type DeepPartial<T> = T extends Function
? T
: T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
/* 範例 */
type Config = {
api: { url: string; timeout: number };
debug: boolean;
};
type PartialConfig = DeepPartial<Config>;
/* -> {
* api?: { url?: string; timeout?: number };
* debug?: boolean;
* } */
3.3. RequireExactlyOne – 必須只提供其中一個屬性
/**
* 讓傳入的物件只能提供 K 中的 **恰好一個** 屬性,其餘屬性皆為 never。
* 常用於「只允許一種驗證方式」的情境(例如 email 或 phone)。
*/
type RequireExactlyOne<T, K extends keyof T = keyof T> = {
[P in K]: Required<Pick<T, P>> &
Partial<Record<Exclude<K, P>, never>>
}[K] &
Omit<T, K>;
type ContactInfo = {
email?: string;
phone?: string;
address?: string;
};
type OneContact = RequireExactlyOne<ContactInfo, "email" | "phone">;
/* -> { email: string; phone?: never; address?: string } |
* { phone: string; email?: never; address?: string } */
3.4. Mutable – 轉換只讀屬性為可寫
/**
* 把物件 T 中的 `readonly` 屬性全部去除,使其變為可寫。
* 這在需要暫時修改已宣告為只讀的 DTO 時特別有用。
*/
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
/* 範例 */
type ReadonlyUser = {
readonly id: number;
readonly name: string;
};
type WritableUser = Mutable<ReadonlyUser>;
/* -> { id: number; name: string } */
3.5. Brand – 建立「帶標記」的類型(防止誤用)
/**
* 透過交叉 (intersection) 加上一個唯一的 brand,讓相同結構的值
* 不會被錯誤地互相代入。常見於 ID、Token 等需要額外語意的字串。
*/
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function getUser(id: UserId) {/* ... */}
function getOrder(id: OrderId) {/* ... */}
const uid: UserId = "u123" as UserId;
const oid: OrderId = "o456" as OrderId;
// getUser(oid); // ❌ 編譯錯誤:類型不相容
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 過度遞迴 | DeepPartial、DeepReadonly 等遞迴型別在極深層結構時可能導致編譯過慢或超出 TypeScript 的遞迴深度上限。 |
盡量限制遞迴層數,或在不需要完整遞迴時改用 Partial<DeepPartial<T>> 之類的局部變形。 |
any 蔓延 |
在條件型別中忘記排除 any,會讓後續推斷失效。 |
使用 unknown 取代 any,或在條件型別前加 extends unknown ? ... : never 確保安全。 |
| 屬性衝突 | RequireExactlyOne 產生的型別會把未選擇的屬性設為 never,若原型別已允許 never,可能產生意外相容。 |
為 never 加上 undefined,或使用 Exclude<... , never> 以避免衝突。 |
| 可讀性下降 | 自訂工具型別過於抽象會讓其他開發者難以理解。 | 為每個工具型別寫完整的 JSDoc,並在專案文件中列出「型別圖」說明其用途。 |
| 編譯錯誤訊息不友善 | 複雜的條件型別在錯誤時會產生長串訊息。 | 使用 type 別名拆解步驟,或在 tsconfig.json 開啟 noErrorTruncation 以取得完整訊息。 |
最佳實踐:
- 先寫測試:自訂工具型別往往牽涉到型別推斷,建議使用
type的單元測試(例如tsd)確保預期行為。 - 保持單一職責:每個工具型別盡量只解決一件事,避免「萬能型別」造成維護困難。
- 適度使用
infer:infer能讓條件型別更簡潔,但過度使用會降低可讀性,僅在必要時使用。 - 文件化:在 README 或 Wiki 中說明自訂工具型別的設計原則與使用範例,降低新成員的學習成本。
實際應用場景
| 場景 | 需求 | 使用的自訂工具型別 |
|---|---|---|
| API 回傳資料的局部更新 | 前端只送出改變的欄位,需要讓後端接受「任意深層的部分」物件。 | DeepPartial<T> |
| 表單驗證 | 同一表單有「只能選擇 Email 或 Phone」的規則。 | `RequireExactlyOne<T, "email" |
| 多語系資源檔 | 需要把所有字串屬性標記為 readonly,但在開發時仍想暫時修改。 |
Mutable<T>(配合 Readonly<T>) |
| 領域模型的唯一標識 | 不同領域的 ID 皆是字串,但要防止相互混用。 | Brand<string, "UserId">、Brand<string, "OrderId"> |
| 重構舊有代碼 | 大型專案中許多介面只需要挑出部份欄位來傳遞。 | MyPick<T, K> 或自訂的 OmitDeep<T, K>(類似 Pick 但支援巢狀) |
案例說明:假設我們在電商平台開發「訂單」與「商品」兩個模組,兩者都使用字串
id作為唯一鍵。若直接使用string,開發者很容易把orderId當成productId傳入函式,導致 API 呼叫錯誤。透過Brand型別,我們可以在編譯階段捕捉這類錯誤,提升系統的型別安全性。
總結
自訂工具型別是 TypeScript 進階型別技巧 中最具威力的武器之一。透過條件型別、映射型別與 infer,我們可以:
- 彈性變形 任意複雜的資料結構
- 減少重複程式,提升團隊一致性
- 在編譯期即捕捉錯誤,降低執行時風險
在實務開發中,建議先從 簡單的 Pick/Partial 變形 開始,逐步擴展到 深層遞迴、唯一標記 等更高階的型別。記得搭配單元測試與完整文件,讓自訂工具型別不僅是程式碼的「魔法」更是團隊的共同資產。
祝你在 TypeScript 的型別世界裡玩得開心,寫出更安全、更可維護的程式碼! 🚀