04 Pick、Omit 与工具类型:复用契约,不复制整套 Model

对应项目:CreateCustomerWidthAddressBody。命名沿用源码;本章不改业务代码。

1. Pick:只选我允许使用的字段

interface CustomerRecord {
  customer_id: number;
  first_name: string;
  last_name: string;
  email: string | null;
  active: number;
  create_date: Date;
}

type CustomerName = Pick<CustomerRecord,
  "customer_id" | "first_name" | "last_name"
>;

得到的类型近似:

interface CustomerName {
  customer_id: number;
  first_name: string;
  last_name: string;
}

Pick 的第二个参数是属性名联合,不是数组。选出的字段保留原类型和可选/只读等修饰。

适合:简要信息、更新白名单、内部数据投影。字段类型与来源保持同步,但也因此产生耦合:原类型发生变化,派生类型跟着变。

2. Omit:排除已有字段,构造新输入

当前创建客户的输入包含 address_id。创建客户并新建地址时,这个 ID 应由程序生成,而不是由调用者传入。

interface CreateCustomerBody {
  store_id: number;
  first_name: string;
  last_name: string;
  email?: string | null;
  address_id: number;
  active?: boolean;
}

interface CreateCustomerWithAddressBody {
  customer: Omit<CreateCustomerBody, "address_id">;
  address: {
    address: string;
    city_id: number;
    phone: string;
  };
}

地址部分是简化教学类型;真实项目还有 district、location 经纬度等必填信息。

这表达的是“沿用客户创建契约,但 address_id 不由本次调用者提供”。与手写两个几乎相同的接口相比,更能表达二者的业务关系。

Pick 强调允许哪些字段;Omit 强调排除哪些字段。面向敏感输入,显式 Pick 白名单通常更稳:基础类型未来加字段时,不会自动允许新字段进入更新输入。

3. Partial:让属性可省略,不是让值随意为空

type UpdateCustomerBody = Partial<Pick<CreateCustomerBody,
  "first_name" | "last_name" | "email" | "address_id"
>>;

表示可以只提供某些字段。它不会自动允许 first_name:null,也不保证“至少提供一项”。

const empty_patch: UpdateCustomerBody = {}; // 类型允许

至少一项、非空名字、合法 ID 等规则仍应由验证链与 Service 检查。单靠复杂类型限制“至少一个字段”,无法验证真实 HTTP 输入。

避免 Partial<Customer>:模型包含主键、默认字段、实例方法、关联等,不等于允许用户修改的字段范围。

4. Required:要求提供属性,但不生成默认值

interface PaginationInput {
  page?: number;
  page_size?: number;
}

type Pagination = Required<PaginationInput>;

function normalizePagination(input: PaginationInput): Pagination {
  return {
    page: input.page ?? 1,
    page_size: input.page_size ?? 20,
  };
}

Required 让编译器检查返回对象提供了两项。真正补默认的是 ?? 表达式。

如果源字段类型显式包含 null,Required 不会自动消除 null;它不是 NonNullable,也不做数值范围校验。

5. Readonly:只读类型视图,默认是浅层

interface Settings {
  port: number;
  tags: string[];
}

const settings: Readonly<Settings> = {
  port: 8080,
  tags: ["learning"],
};

settings.port = 9000; // 错误
settings.tags.push("node"); // 合法:内部数组没有变只读

若连 tags 都不应修改,就把字段定义成 readonly string[]。类型只读也不会冻结运行时对象,见 01 章。

6. Record:固定映射与开放字典要区分

type Status = "pending" | "active" | "banned";
const labels: Record<Status, string> = {
  pending: "待处理",
  active: "已启用",
  banned: "已禁用",
};

要求三种状态都有对应文案。

const lookup: Record<string, string> = {};
const name = lookup["missing"]; // 当前配置下:string | undefined

开放字典不保证每个任意字符串键真的存在。noUncheckedIndexedAccess 会提醒你这点;不要靠 Record<string, ...> 把固定配置的非法索引问题掩盖掉。

7. Omit / Pick 不会删除运行时字段

const full = { customer_id: 1, first_name: "ANN", password_hash: "secret" };
const summary: Pick<typeof full, "customer_id" | "first_name"> = full;

console.log(JSON.stringify(summary)); // 实际仍含 password_hash

只是改变静态视图,full 对象没有被复制或过滤。这段不应作为真实敏感数据响应。

真正构造 DTO:

const summary = {
  customer_id: full.customer_id,
  first_name: full.first_name,
};

变量赋值可包含额外字段,是结构化类型系统的行为;第 12 章 会解释它与对象字面量检查的差别。

8. 修改字段类型,别直接用交叉类型硬覆盖

Model 的 active 是 number,DTO 想要 boolean:

type WrongDto = CustomerRecord & { active: boolean };
// active 必须同时是 number 和 boolean,常会成为不可满足的 never。

需要保留大部分字段时,先排除再定义:

type CustomerDto = Omit<CustomerRecord, "active" | "create_date"> & {
  active: boolean;
  create_date: string;
};

类型仍不会自动转换,mapper 要执行数字→布尔、Date→ISO。对公共 API,更独立、显式的 DTO 往往更容易控制兼容性;不是所有 DTO 都要从 Model 派生。

9. 什么值得复用?

真正相同的业务契约、稳定的分页结构、固定配置键范围,适合复用。只是碰巧都叫 name、email,却属于不同接口版本或数据边界的类型,不应强行绑定。

创建输入、数据库字段与 API 输出的变化方向不同,要先看语义,再选择工具类型。

10. 练习

  1. 用 Pick 定义允许修改的名字和邮箱;再用 Partial 允许部分修改。
  2. 给源类型新增 token_version,确认更新白名单是否意外扩大。
  3. 对 summary 执行 JSON.stringify,亲自验证 Pick 不过滤字段。
  4. 为不同的 create 输入设计 Omit,说明被省略的字段由谁产生。

下一章:null、undefined 与可选属性。参考:Utility Types。