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 的联合类型问题——将帮助你避免以后难以调试的类型错误。

typescriptutility-typestype-systemfrontend