TypeScript 中的 Utility Types:Partial、Pick、Omit、Record
1. 概述
编写 TypeScript 时,你会遇到这样的情况:需要一个与已有类型非常相似但缺少或多出几个字段的类型。与其声明新的 interface,不如利用 TypeScript 内置的 utility types 来转换现有类型。本文将介绍四种最常用的 utility type:Partial、Pick、Omit、Record,附带实际例子和常见错误。读完本文,你将学会为每种情况选择正确的 utility type,而不是手动重写 interface。
2. Partial<T>
Partial<T> 将 T 的所有字段变成可选的。这在编写更新函数时很有用,调用者只需传入部分字段。
interface Customer {
id: number;
name: string;
email: string;
phone: string;
}
function updateCustomer(id: number, changes: Partial<Customer>): Customer {
const current = findCustomerById(id); // 假设此函数已存在
return { ...current, ...changes };
}
updateCustomer(1, { email: "new@mail.com" });
Partial<Customer> 创建一个等效的类型:
interface PartialCustomer {
id?: number;
name?: string;
email?: string;
phone?: string;
}
因此,updateCustomer 接受仅包含 email 的对象,不会报告缺少字段的错误。
注意:
Partial仅在第一层(shallow)其成为可选。如果Customer有嵌套对象字段,其内部字段仍然是必需的。要实现深层 partial,你需要自己编写递归类型或使用type-fest这样的库。
3. Pick<T, K>
Pick<T, K> 从 T 中根据关键字列表 K 选择一个字段子集。当你只需要大类型的部分数据时使用,例如返回给客户端的 DTO。
interface Order {
id: string;
customerId: number;
totalAmount: number;
items: string[];
createdAt: Date;
internalNote: string;
}
type OrderSummary = Pick<Order, "id" | "totalAmount" | "createdAt">;
const summary: OrderSummary = {
id: "ORD-001",
totalAmount: 250_000,
createdAt: new Date(),
};
OrderSummary 仅包含 3 个字段。如果你向 summary 添加 items 或 internalNote 字段,编译器会报错 Object literal may only specify known properties。
3.1. Pick 结合联合键
传入 Pick 的关键字是字符串字面量联合,因此你可以将其拆分为单独的变量以便重用:
type OrderPublicFields = "id" | "totalAmount" | "createdAt";
type OrderSummary = Pick<Order, OrderPublicFields>;
type OrderListItem = Pick<Order, OrderPublicFields | "customerId">;
这样做避免了在有多个来自同一实体的 DTO 变体时重复列出关键字。
4. Omit<T, K>
Omit<T, K> 与 Pick 相反:排除指定的键,保留其余部分。通常用于隐藏敏感字段或内部字段。
interface User {
id: number;
username: string;
passwordHash: string;
role: string;
}
type PublicUser = Omit<User, "passwordHash">;
function toPublicUser(user: User): PublicUser {
const { passwordHash, ...rest } = user;
return rest;
}
行 const { passwordHash, ...rest } = user 将 passwordHash 分离出来,rest 仅包含 id、username、role——完全匹配 PublicUser 类型。
// console.log(toPublicUser(user)) 的输出
{ id: 1, username: "nonla_dev", role: "ADMIN" }
注意:
Omit是基于Pick和Exclude定义的,适用于对象字面量类型,但在某些复杂情况下不能正确排除重载或歧视联合。对于联合类型(type A = B | C),Omit<A, K>会在排除前合并各个成员,可能会失去判别器属性。遇到复杂联合时,考虑手动编写类型而不是直接使用Omit。
5. Record<K, T>
Record<K, T> 创建一个对象类型,其中所有键属于 K,每个键的值类型为 T。当建模具有固定键的 map/dictionary 时很有用。
type PaymentStatus = "PENDING" | "PAID" | "CANCELLED";
interface StatusInfo {
label: string;
color: string;
}
const statusDisplay: Record<PaymentStatus, StatusInfo> = {
PENDING: { label: "待支付", color: "orange" },
PAID: { label: "已支付", color: "green" },
CANCELLED: { label: "已取消", color: "gray" },
};
function getStatusLabel(status: PaymentStatus): string {
return statusDisplay[status].label;
}
由于 statusDisplay 声明为 Record<PaymentStatus, StatusInfo> 类型,编译器强制你必须拥有所有三个键 PENDING、PAID、CANCELLED。如果你忘记了一个键,TypeScript 会在编译时立即报错——这是相比使用常规 { [key: string]: StatusInfo}(索引签名不检查足够的键)的优势。
5.1. Record 与动态键
当键不是联合字面量而是普通 string 时,Record<string, T> 等同于索引签名:
const cache: Record<string, number> = {};
cache["order-1"] = 100;
这种情况下不再检查“足够的键”,因为 string 有无限多个值。仅当你真正需要自由字典时才使用 Record<string, T>,否则应该优先使用联合字面量以利用穷举检查。
6. 结合多个 Utility Type
多个 utility type 可以嵌套以创建更复杂的类型,而无需编写新的 interface。
interface Product {
id: string;
name: string;
price: number;
stock: number;
supplierNote: string;
}
type ProductUpdateRequest = Partial<Omit<Product, "id">>;
function updateProduct(id: string, data: ProductUpdateRequest) {
// 调用 repository.update(id, data)
}
updateProduct("P001", { price: 199_000 });
Omit<Product, "id"> 排除 id 字段(不允许修改主键),然后 Partial 将其余字段变为可选。结果是:调用者只需传入想要更改的字段,不允许传入 id。
7. 编写测试验证行为
由于 utility type 只存在于编译时,验证的实际方法是为使用这些类型的函数编写单元测试,确保运行时行为符合预期。例如使用 Vitest:
<!-- package.json 依赖项等价(不是 Maven)-->
import { describe, it, expect } from "vitest";
interface User {
id: number;
username: string;
passwordHash: string;
role: string;
}
type PublicUser = Omit<User, "passwordHash">;
function toPublicUser(user: User): PublicUser {
const { passwordHash, ...rest } = user;
return rest;
}
describe("toPublicUser", () => {
it("从结果中移除 passwordHash", () => {
const user: User = {
id: 1,
username: "nonla_dev",
passwordHash: "hash-123",
role: "ADMIN",
};
const result = toPublicUser(user);
expect(result).toEqual({
id: 1,
username: "nonla_dev",
role: "ADMIN",
});
expect(result).not.toHaveProperty("passwordHash");
});
});
此测试确认函数 toPublicUser 不仅在类型上正确,而且在运行时也正确——passwordHash 真正从返回的对象中消失,不仅仅是在类型层隐藏。
8. 常见错误和何时不应使用
- 误认为
Pick/Omit会删除实际数据: 两者只改变类型,不影响运行时对象。只有解构(const { x, ...rest } = obj)才真正从数据中移除字段。 - 过度使用
Partial处理 API 返回对象: 如果响应确实总是包含所有字段,使用Partial会强制调用代码在各处进行不必要的undefined检查。仅为输入(update/patch)使用Partial,不应用于已确认完整的输出。 Omit与复杂联合类型: 如第 4 节所述,Omit可能会丢失判别联合。如果你的类型是多个变体的联合(例如Shape = Circle | Square),请自己编写类型或使用条件映射类型技术,而不是直接使用Omit。- 用
Record<string, T>替代清晰的 interface: 如果你预先知道所有的键,请声明联合字面量并使用Record<Key, T>以进行穷举检查。随意使用Record<string, T>会失去在编译时检测缺少/多余键的能力。
注意: 所有这些 utility type 仅在编译时工作。构建成 JavaScript 后,它们完全消失——没有运行时开销,但也没有运行时验证。如果数据来自外部(API、JSON 文件),你仍然需要使用 Zod 或 io-ts 等库进行验证。
9. 结论
Partial、Pick、Omit 和 Record 帮助你重用类型而不是重写重复的 interface。Partial 适合于 update 形式的输入,Pick/Omit 用于从大型实体创建 DTO,而 Record 非常适合具有固定键的字典,利用编译器的穷举检查。清楚地理解每种类型的限制——特别是 Partial 的浅层行为和 Omit 的联合类型问题——将帮助你避免以后难以调试的类型错误。