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. 练习
- 用 Pick 定义允许修改的名字和邮箱;再用 Partial 允许部分修改。
- 给源类型新增 token_version,确认更新白名单是否意外扩大。
- 对 summary 执行 JSON.stringify,亲自验证 Pick 不过滤字段。
- 为不同的 create 输入设计 Omit,说明被省略的字段由谁产生。
下一章:null、undefined 与可选属性。参考:Utility Types。