TypeScript 工具型別(Utility Types)
主題:Partial<T>
簡介
在大型的 TypeScript 專案中,我們常會面臨「物件只需要部份屬性」的情境,例如更新表單、API 的 PATCH 請求,或是將資料切割成多個介面重組。若每次都手動把所有屬性改成可選 (?) ,不僅繁瑣,還容易遺漏或造成型別不一致。
Partial<T> 正是為了解決這類需求而設計的 工具型別。它能在編譯期自動把給定型別 T 的所有屬性變為可選,讓開發者只需要關注「哪些屬性會被提供」而不必重複撰寫冗長的型別宣告。
本篇文章將從概念、實作細節、常見陷阱到實務應用,完整說明 Partial<T> 的使用方式,適合 從初學者到中階開發者 的所有人閱讀與參考。
核心概念
什麼是 Partial<T>
Partial<T> 是 TypeScript 內建的 泛型工具型別,其宣告大致如下(實際實作在 lib.es5.d.ts 中):
type Partial<T> = {
[P in keyof T]?: T[P];
};
簡單來說,它會遍歷 T 的所有屬性鍵 (keyof T),然後把每個鍵對應的屬性型別 T[P] 包裝成 可選 (?)。最終得到的型別是一個「所有屬性皆為可選」的物件型別。
重點:
Partial<T>不會改變原本的型別,它只會回傳一個全新型別。原始型別仍然保持不變,這對於型別安全非常重要。
為什麼要使用 Partial<T>
| 場景 | 若不使用 Partial<T> |
使用 Partial<T> 的好處 |
|---|---|---|
| 表單編輯 | 每次都手寫 interface EditUser { name?: string; age?: number; ... } |
只要 Partial<User>,自動同步屬性變更 |
| PATCH API | 必須自行建立 type PatchUser = Partial<User> 或手動列出 |
直接傳入 Partial<User>,減少維護成本 |
| 函式參數 | function update(opts: { name?: string; age?: number; ... }) |
function update(opts: Partial<User>),更具可讀性 |
| 測試資料 | 測試時常只需要部份欄位 | Partial<User> 讓測試資料更彈性 |
Partial<T> 的實作原理
- 映射型別 (
[P in keyof T]):遍歷T的所有屬性鍵。 - 可選屬性 (
?:):在每個屬性後加上?,使其變為可選。 - 屬性型別保留 (
T[P]):屬性的實際型別不變,只是變成可選。
如果 T 本身已經有可選屬性,Partial<T> 仍會保留可選狀態,且不會把必填屬性變成必填——全部都會是 可選。
程式碼範例
以下示範 5 個常見且實用的 Partial<T> 用法,並配合註解說明每一步的意圖。
1️⃣ 基本範例:把完整介面變成可選
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
// 直接使用 Partial
type UpdateUserDTO = Partial<User>;
const update1: UpdateUserDTO = {
// 只需要提供想要更新的欄位
name: "Alice",
};
說明:
UpdateUserDTO會變成{ id?: number; name?: string; email?: string; isAdmin?: boolean },因此在更新時只需要提供被修改的欄位即可。
2️⃣ 結合函式參數:實作通用的 update 函式
function updateUser(id: number, changes: Partial<User>) {
// 假設有一個全域的 userMap
const user = userMap.get(id);
if (!user) throw new Error("User not found");
// 使用 Object.assign 合併變更
Object.assign(user, changes);
}
// 呼叫範例
updateUser(1, { email: "new@example.com", isAdmin: true });
技巧:因為
changes是Partial<User>,IDE 會自動提供屬性補全,且不會允許不存在的屬性。
3️⃣ 與 Pick<T, K> 結合:只允許部份屬性可選
有時候我們只想讓 特定 屬性可選,而其他屬性仍保持必填。可以先 Pick 需要的屬性,再套用 Partial。
// 只讓 name 與 email 可選,其他保持必填
type UserUpdate = Partial<Pick<User, "name" | "email">> & Omit<User, "name" | "email">;
const update2: UserUpdate = {
id: 2, // 必填
isAdmin: false // 必填
// name、email 可以省略或提供
};
重點:
Pick+Partial+Omit的組合讓我們可以細緻控制哪些欄位是必填、哪些是可選。
4️⃣ 深層 Partial(遞迴版)
原生的 Partial<T> 只會把第一層屬性設為可選,若屬性本身是物件,內部屬性仍保持原本的必填狀態。以下示範自訂遞迴 DeepPartial<T>:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
interface Profile {
user: User;
settings: {
theme: string;
notifications: boolean;
};
}
// DeepPartial 讓所有子層都變可選
type PartialProfile = DeepPartial<Profile>;
const profilePatch: PartialProfile = {
user: { email: "patched@example.com" }, // 只需要提供想改的欄位
settings: { notifications: false } // 同上
};
實務提醒:遞迴型別會增加編譯時間,僅在需要深層更新時使用。
5️⃣ 與類別結合:使用 Partial<T> 建構物件
class Product {
constructor(
public id: number,
public name: string,
public price: number,
public tags: string[]
) {}
}
// 讓建構子接受 Partial<Product>
function createProduct(data: Partial<Product>): Product {
// 預設值可以寫在這裡
const defaults: Product = new Product(0, "Unnamed", 0, []);
return Object.assign(defaults, data);
}
// 呼叫
const p = createProduct({ name: "Laptop", price: 1200 });
說明:
createProduct允許呼叫者只提供需要的欄位,而剩餘欄位會使用預設值。這在「工廠函式」或「測試資料生成」時非常實用。
常見陷阱與最佳實踐
1️⃣ 過度使用 Partial<T> 造成型別過寬
如果把整個大型介面直接變成 Partial,可能會把不該被修改的欄位也變成可選,導致 API 接收不完整資料。建議:只在真正需要的情境(如 PATCH)使用,或搭配 Pick、Omit 限制範圍。
2️⃣ Partial 只作用於第一層
如前所述,Partial<User> 不會遞迴處理巢狀物件。若有深層結構且需要部份更新,請自行實作 DeepPartial 或使用第三方套件(如 type-fest 的 PartialDeep)。
3️⃣ 與 readonly 結合的衝突
Partial<T> 只會把屬性變為可選,不會改變 readonly 修飾。如果你想同時把屬性設為可寫,需再結合 -readonly:
type MutablePartial<T> = {
-readonly [P in keyof T]?: T[P];
};
4️⃣ 在函式重載時的型別推斷
當同一函式接受完整型別或 Partial 時,TypeScript 可能無法正確推斷返回值型別。此時可以使用 條件型別 或 函式重載 明確區分:
function saveUser(user: User): User;
function saveUser(user: Partial<User>): Partial<User>;
function saveUser(user: any): any {
// implementation
}
5️⃣ 避免在公共 API 中直接暴露 Partial<T>
對外的 API(例如 npm 套件)若直接回傳 Partial<T>,使用者可能會依賴「可選」的屬性,未來若你改變了原始介面,會造成相容性問題。最佳實踐:在公開層面定義專屬的 DTO(Data Transfer Object),內部使用 Partial<T> 進行實作。
實際應用場景
| 場景 | 為什麼適合使用 Partial<T> |
|---|---|
| 表單編輯 UI | 使用 Partial<FormValues> 讓表單狀態只儲存變更過的欄位,減少不必要的重繪。 |
| RESTful PATCH | 後端只接受部份欄位更新,前端可直接傳 Partial<Resource>,型別安全且省去手寫介面。 |
| Redux / Zustand 狀態合併 | setState(prev => ({ ...prev, ...partial })),partial 可用 Partial<State> 表示。 |
| 測試資料生成 | factory<User>(partial => ({ id: 1, name: "Bob", ...partial })),讓測試只關心需要的欄位。 |
| 微服務間資料同步 | 當服務 A 只需要更新服務 B 中的部分欄位時,使用 Partial<SharedModel> 來保證欄位名稱一致且不會遺漏。 |
總結
Partial<T> 是 TypeScript 提供的 高效、易用 的工具型別,讓開發者可以在不改動原始介面的前提下,快速產生「所有屬性皆為可選」的型別。透過本文的概念說明、實作範例與最佳實踐,你應該已經能夠:
- 正確使用
Partial<T>於函式參數、API 請求、狀態管理等情境。 - 辨識 何時應該搭配
Pick、Omit或自訂遞迴型別,以避免過度寬鬆的型別。 - 避免 常見的陷阱,如第一層限制、
readonly衝突與公共 API 的相容性問題。
在日常開發中,善用 Partial<T> 能大幅減少重複的型別宣告,提高程式碼可讀性與維護性。未來若遇到更複雜的「部份更新」需求,記得考慮 DeepPartial 或其他進階工具型別,讓型別安全與彈性兼得。祝你在 TypeScript 的世界裡寫出更乾淨、更可靠的程式碼!