本文 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); // ❌ 編譯錯誤:類型不相容

常見陷阱與最佳實踐

陷阱 說明 解決方式
過度遞迴 DeepPartialDeepReadonly 等遞迴型別在極深層結構時可能導致編譯過慢或超出 TypeScript 的遞迴深度上限。 盡量限制遞迴層數,或在不需要完整遞迴時改用 Partial<DeepPartial<T>> 之類的局部變形。
any 蔓延 在條件型別中忘記排除 any,會讓後續推斷失效。 使用 unknown 取代 any,或在條件型別前加 extends unknown ? ... : never 確保安全。
屬性衝突 RequireExactlyOne 產生的型別會把未選擇的屬性設為 never,若原型別已允許 never,可能產生意外相容。 never 加上 undefined,或使用 Exclude<... , never> 以避免衝突。
可讀性下降 自訂工具型別過於抽象會讓其他開發者難以理解。 為每個工具型別寫完整的 JSDoc,並在專案文件中列出「型別圖」說明其用途。
編譯錯誤訊息不友善 複雜的條件型別在錯誤時會產生長串訊息。 使用 type 別名拆解步驟,或在 tsconfig.json 開啟 noErrorTruncation 以取得完整訊息。

最佳實踐

  1. 先寫測試:自訂工具型別往往牽涉到型別推斷,建議使用 type 的單元測試(例如 tsd)確保預期行為。
  2. 保持單一職責:每個工具型別盡量只解決一件事,避免「萬能型別」造成維護困難。
  3. 適度使用 inferinfer 能讓條件型別更簡潔,但過度使用會降低可讀性,僅在必要時使用。
  4. 文件化:在 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 的型別世界裡玩得開心,寫出更安全、更可維護的程式碼! 🚀