工具类型(utility type) 根据已有类型派生新类型,保留字段之间的关系,避免手写多份容易漂移的对象、联合或函数契约。
Partial 与 Readonly 默认只处理第一层,Omit 也不会删除运行时属性;这些工具改变的是静态可赋值关系,不是数据本身。
先写清允许改变、暴露或索引的集合,再选择最窄的工具类型;对外部数据仍做运行时构造或验证,并用严格编译选项检查边界。
是什么,为什么存在
TypeScript 工具类型是一组由标准库提供的泛型类型别名。它们接收一个或多个类型,再产生新类型,例如把现有对象的一部分字段变为可选、选择公开字段、过滤联合成员,或取得函数的参数元组。结果只供类型检查器使用,不会生成 JavaScript 函数或对象。
工具类型解决的是「相关契约如何保持同步」。若 Invoice 增加字段,手写的 InvoicePreview、InvoicePatch 和状态索引可能悄悄落后;用 Pick、Partial 与 Record 表达来源和转换后,编译器可以在来源变化时重新计算派生类型。代码评审看到的也不再只是两个相似接口,而是二者之间的规则。
你会在更新命令、公开响应、状态处理表、函数包装器和联合过滤器中遇到这些类型。它们最适合派生关系确实属于领域契约的场景;若两个形状只是今天碰巧相似,独立命名通常更诚实,因为未来变化未必应该联动。
工具类型不会验证数据,也不会复制、冻结或删改对象。这个边界来自 类型擦除(type erasure) :编译后,Partial<Account> 与 Omit<Account, "passwordHash"> 都不存在。涉及网络输入、权限字段或秘密值时,必须另写运行时代码完成检查和投影。
按意图选择
| 意图 | 常用工具 | 关键问题 |
|---|---|---|
| 改变属性修饰符 | Partial、Required、Readonly | 只需要第一层,还是嵌套层也要改变? |
| 选择对象字段 | Pick、Omit | 允许列表与排除列表中,哪个在未来更安全? |
| 建立键到值的映射 | Record | 键是封闭联合,还是任意运行时字符串? |
| 过滤联合成员 | Exclude、Extract、NonNullable | 判断依据是可赋值关系,而非字段名称吗? |
| 复用函数形状 | Parameters、ReturnType、Awaited | 函数是否有重载或泛型关系? |
Pick 是允许列表:只有列出的字段进入结果,适合公开 DTO 和受限更新。Omit 是排除列表:来源新增字段时,新字段会自动进入结果,适合「除少数基础设施字段外都保留」的内部转换。这个差异不是风格问题,而是来源扩展时默认允许还是默认拒绝的策略。
来源变化如何传播
派生类型把来源变化变成编译期反馈,但不同工具的反馈方向不同。评审一个公共类型时,应先模拟来源增加、删除和修改字段,再决定自动传播是否符合兼容性策略。
| 来源变化 | Pick<T, K> | Omit<T, K> |
|---|---|---|
| 新增字段 | 默认不进入结果 | 默认进入结果 |
| 删除被点名字段 | K 约束产生错误 | 排除名可静默失效 |
| 修改保留字段的值类型 | 传播到结果 | 传播到结果 |
| 修改保留字段的修饰符 | 传播到结果 | 传播到结果 |
对外响应通常更希望来源新增字段时默认不公开,所以 Pick 比 Omit 稳妥。内部持久化准备步骤可能恰好相反:只删除 id 和时间戳后,其余领域字段都应继续传播,此时 Omit 更直接。
Partial、Required 与 Readonly 会覆盖整组第一层键的某个修饰维度。若业务规则只适用于部分字段,应先缩小键集合,或者把需要不同规则的字段分别派生后再组合,避免把技术便利误写成领域权限。
自动传播不能替代版本判断。来源字段的类型从 string 改为字面量联合,即使派生类型自动更新,仍可能破坏使用方;公开库应查看声明输出并运行使用方类型测试。
工作原理
大多数对象工具建立在 映射类型(mapped type) 上。映射类型遍历 keyof T 得到的属性键,通过 T[P] 读取对应值类型,并可添加或移除 ?、readonly 修饰符。它重新描述属性,不会遍历或改变运行时对象。
属性映射与选择
| 工具 | 派生结果 | 不会做的事 |
|---|---|---|
Partial<T> | 把 T 的每个第一层属性标为可选 | 不会递归,也不会创建默认值 |
Required<T> | 移除每个第一层属性的可选标记 | 不会证明运行时值已存在 |
Readonly<T> | 把每个第一层属性标为只读 | 不会冻结对象或嵌套值 |
Pick<T, K> | 保留键集合 K 对应的属性 | 不会从对象中复制这些字段 |
Omit<T, K> | 保留 T 中不属于 K 的属性 | 不会从对象中删除 K |
Record<K, V> | 为 K 的每个键要求一个 V 值 | 不会检查运行时新增的任意键 |
Pick<T, K> 要求 K 属于 keyof T,因此字段拼错通常立即报错。标准 Omit<T, K> 的 K 可以是任意属性键;Omit<Account, "paswordHash"> 可能合法但什么也没排除。对秘密字段或迁移代码,应增加类型测试,或用约束为 K extends keyof T 的本地严格包装器。
修饰符会保留未被明确改变的部分。Partial<T> 不会去掉 readonly,Readonly<T> 也不会自动让可选字段变为必填。因此 Readonly<Partial<T>> 表达的是第一层属性既可缺省又不可重新赋值,而不是「完整且不可变」。
联合过滤
Exclude、Extract 与 NonNullable 建立在 条件类型(conditional type) 上。对于裸类型参数表示的联合,条件会分配到每个成员,再把各成员的结果重新组成联合。这就是 Exclude<"draft" | "paid", "paid"> 得到 "draft" 的原因。
| 工具 | 对每个联合成员的规则 |
|---|---|
Exclude<T, U> | 成员可赋给 U 时丢弃,否则保留 |
Extract<T, U> | 成员可赋给 U 时保留,否则丢弃 |
NonNullable<T> | 丢弃 null 与 undefined |
这里比较的是可赋值性,而不是名义上的标签。Extract<Shape, { kind: "circle" }> 能筛出匹配的对象成员,是因为该成员可赋给目标形状;如果目标还要求来源成员没有的字段,结果可能是 never。筛选条件越具体,越要检查自己是否无意中排除了合法成员。
函数与构造器的形状
Parameters<F> 产生参数元组,ReturnType<F> 取得函数返回类型。ConstructorParameters<C> 与 InstanceType<C> 对构造签名做对应转换;Awaited<T> 则递归取得 Promise 或兼容 thenable 最终兑现的类型。
这些工具应接收函数的类型,而不是函数调用结果。常见写法是 ReturnType<typeof buildInvoice> 和 Parameters<typeof buildInvoice>;漏掉 typeof 后,类型位置无法直接使用运行时函数值。异步函数的 ReturnType 仍是 Promise<...>,需要最终值时再套 Awaited。
重载函数需要额外注意。条件类型从多个调用签名推断时使用最后一个签名,通常是最宽的实现兼容签名,因此 Parameters 或 ReturnType 未必保留每个重载之间的精确对应关系。包装器若必须保留重载,应显式写出公开重载或重新设计成可辨识参数联合。
静态结构与运行时值
TypeScript 使用 结构类型(structural typing) :值只要拥有所需结构,就可赋给派生类型。一个完整 Account 值因此可以赋给 Omit<Account, "passwordHash">,因为目标只是不再要求那个字段,并没有禁止额外字段。
对象字面量在某些直接赋值位置会接受额外属性检查,但先保存到变量、从函数返回或经过断言后,行为可能不同。不能把这种检查当作「对象中绝无额外属性」的证明。若响应必须不含秘密,应构造一个只包含允许字段的新对象,并对最终序列化结果测试。
示例
下面四个示例围绕账单领域逐步使用选择、修饰、联合过滤、只读与函数形状工具。所有输出均由本地 npx tsx 实际执行得到,源码还通过了 TypeScript 6.0.3 的严格检查。
派生最小更新契约
先用 Pick 明确允许修改的字段,再用 Partial 允许调用方只提交其中一部分。id 和 status 从未进入更新类型,比直接写 Partial<Invoice> 更能表达权限边界。
interface Invoice {
readonly id: string;
recipient: { name: string; email: string };
note: string;
status: "draft" | "sent" | "paid";
}
type EditableInvoice = Pick<Invoice, "recipient" | "note">;
type InvoicePatch = Partial<EditableInvoice>;
function applyPatch(current: Invoice, patch: InvoicePatch): Invoice {
return { ...current, ...patch };
}
const invoice: Invoice = {
id: "inv-42",
recipient: { name: "Mina", email: "[email protected]" },
note: "Due on receipt",
status: "sent",
};
const revised = applyPatch(invoice, { note: "Payable in 30 days" });
console.log(`${revised.id}: ${revised.note}`);
console.log(revised.recipient.email);inv-42: Payable in 30 days
[email protected]对象展开创建了新账单并保留未更新字段,但这是运行时函数自己的行为,不是 Partial 的效果。Partial 只允许 patch 缺少第一层字段;若提交 recipient,仍必须提供完整的 name 和 email。
这个设计也没有执行运行时验证。若补丁来自 JSON,应先从 unknown 检查允许字段、值类型与未知字段策略,再调用 applyPatch。类型注解只约束已进入 TypeScript 信任边界的数据。
让封闭状态表保持完整
Exclude 和 Extract 从同一状态联合派生活跃与终态集合,Record 则要求每个账单状态都有后续动作。新增状态后,动作表缺少对应键会产生编译错误。
type InvoiceStatus = "draft" | "sent" | "overdue" | "paid" | "void";
type ActiveStatus = Exclude<InvoiceStatus, "paid" | "void">;
type TerminalStatus = Extract<InvoiceStatus, "paid" | "void">;
const nextAction: Record<InvoiceStatus, string> = {
draft: "edit",
sent: "wait",
overdue: "remind",
paid: "archive",
void: "retain",
};
function describe(status: InvoiceStatus): string {
return `${status}: ${nextAction[status]}`;
}
const active: readonly ActiveStatus[] = ["draft", "sent", "overdue"];
const terminal: readonly TerminalStatus[] = ["paid", "void"];
console.log(active.map(describe).join(" | "));
console.log(terminal.map(describe).join(" | "));draft: edit | sent: wait | overdue: remind
paid: archive | void: retain这里的安全索引来自 status: InvoiceStatus 与有限键集合完全一致。若参数只是 string,Record<InvoiceStatus, string> 不能证明该字符串是合法状态;入口处仍需验证或收窄。
使用 Record<string, string> 会表达另一份契约:任何字符串键都被视为有字符串值。普通 JavaScript 对象并不自动满足这个运行时承诺,因此动态字典通常要配合 noUncheckedIndexedAccess、显式缺失检查,或者改用 Map。
看见浅层 Readonly
Readonly<QueueState> 阻止给 name 或 jobs 属性重新赋值,却没有改变 jobs 自身的数组类型。要让快照的数组也只读,必须在源契约中把它声明为 readonly string[],或使用经过领域设计的递归类型。
interface QueueState {
name: string;
jobs: string[];
}
const shallow: Readonly<QueueState> = {
name: "billing",
jobs: ["draft"],
};
shallow.jobs.push("send");
console.log(shallow.jobs.join(","));
type QueueSnapshot = Readonly<{ name: string; jobs: readonly string[] }>;
const before: QueueSnapshot = { name: "billing", jobs: ["draft"] };
const after: QueueSnapshot = { ...before, jobs: [...before.jobs, "send"] };
console.log(before.jobs.join(","));
console.log(after.jobs.join(","));draft,send
draft
draft,send第二种写法使数组变更在类型层被拒绝,并通过创建新数组表达状态转换。它仍不等于运行时冻结:来自 JavaScript、断言或可变别名的代码仍可能改动同一对象,所以不可信边界与共享可变状态需要独立控制。
复用异步函数契约
包装器用 Parameters 复用参数元组,用 Awaited<ReturnType<...>> 得到最终账单。原函数增删参数或修改返回结构时,包装器签名会随之接受检查。
async function loadInvoice(id: string, includeTax: boolean) {
const subtotalCents = 2400;
return {
id,
totalCents: includeTax ? subtotalCents + 480 : subtotalCents,
};
}
type LoadArgs = Parameters<typeof loadInvoice>;
type LoadedInvoice = Awaited<ReturnType<typeof loadInvoice>>;
async function tracedLoad(...args: LoadArgs): Promise<LoadedInvoice> {
console.log(`load ${args[0]} tax=${args[1]}`);
return loadInvoice(...args);
}
async function main(): Promise<void> {
const invoice = await tracedLoad("inv-42", true);
console.log(`${invoice.id}: ${invoice.totalCents}`);
}
void main();load inv-42 tax=true
inv-42: 2880类型复用减少了签名重复,但不会验证实现语义。生成的包装器仍可能打乱参数、吞掉拒绝、记录秘密或改变 this 绑定;这些都需要运行时测试和代码评审。
陷阱
把第一层转换当作递归转换
修复方法: 明确列出真正需要递归的节点,优先为领域命令写专用类型。确实需要通用递归工具时,把数组、元组、函数和内建对象分别处理,并到 typescript/custom-utility-types 检查相应边界,不能只写一行 T[P] extends object。
把 Omit 当作数据脱敏
修复方法: 在运行时用解构或明确允许列表构造新对象,并测试 JSON.stringify 或实际传输载荷。秘密字段采用默认拒绝的投影;不要依赖返回类型注解、断言或额外属性检查执行删除。
用整个实体的 Partial 设计补丁
修复方法: 先用 Pick 建立允许更新的字段集合,再决定哪些字段可缺省。对嵌套补丁定义明确命令并在运行时拒绝未知键;权限不同的操作使用不同补丁类型。
混淆缺省与显式 undefined
修复方法: 为 API 明确缺省、清空与设为 undefined 的语义,启用 exactOptionalPropertyTypes,并在需要清空时使用明确的 null 或操作标签。测试对象是否拥有键,不能只用真值判断。
用 Record<string, V> 保证任意查找存在
修复方法: 键集合封闭时使用字面量联合;键集合开放时启用 noUncheckedIndexedAccess 并处理缺失值,或使用返回 V | undefined 的 Map#get。外部字符串先验证再索引。
让拼错的 Omit 静默通过
修复方法: 对安全敏感的排除字段增加负向类型测试,或定义 StrictOmit<T, K extends keyof T> = Omit<T, K>。公开响应更适合使用 Pick 允许列表,并同时验证运行时序列化结果。
组合为何有效
工具类型的结果仍是普通类型,因此可以继续作为另一个工具的输入。Partial<Pick<Invoice, "note" | "recipient">> 先限制字段集合,再改变修饰符;从内向外阅读可以看清每一步。为复杂组合命名中间类型,通常比堆叠四层尖括号更容易评审。
组合顺序有时改变结果。先 Pick 再 Partial 与先 Partial 再 Pick 在简单对象上通常等价,但「允许哪些字段」应优先出现在命名与评审流程中。对于条件类型、键重映射或互相冲突的修饰符,不能假设交换顺序仍相同,应写类型测试证明期望的可赋值关系。
Required<Partial<T>> 不一定恢复原始 T 的全部语义。它会让第一层键重新必填,但编译选项与原始可选属性的值类型会影响是否仍含 undefined;嵌套层也从未改变。不要把看似相反的工具当作通用可逆运算。
类型别名只保存计算规则,不会保存某一版本的字段快照。来源类型变化后,派生结果会立即变化,这既是价值也是风险。公开边界需要声明输出差异、类型测试或 API 评审,避免新增内部字段自动泄漏到基于 Omit 的外部契约。
分配、never 与过滤结果
分配条件类型逐个处理联合成员。概念上,Exclude<A | B, U> 会计算 Exclude<A, U> | Exclude<B, U>;被排除的分支产生 never,而 never 在联合中消失。Extract 只是交换保留与丢弃分支。
这一机制解释了为什么对象联合可以按形状过滤,也解释了意外的空结果。若每个成员都可赋给排除目标,Exclude 得到 never;若没有成员满足提取目标,Extract 也得到 never。可用赋值测试或 satisfies 固定预期成员,避免空类型在更远处才暴露。
分配只发生在被检查一侧是裸类型参数的特定形式。自定义条件类型把参数包进单元素元组后,可以把联合整体比较并阻止分配。这个细节属于自定义工具类型设计;使用内建 Exclude 与 Extract 时,应按逐成员过滤理解。
NonNullable<T> 也是过滤,而不是运行时空值检查。函数返回 NonNullable<T> 之前必须通过控制流检查、解析或构造证明值非空;使用 as NonNullable<T> 只是在关闭诊断,不能改变 null 或 undefined。
编译选项补全契约
strict 是起点,但工具类型常受更具体的选项影响。exactOptionalPropertyTypes 把 property?: T 更精确地解释为「属性可缺省」,不会自动允许赋值 undefined,除非 T 本身包含它。这让 Partial<T> 更接近许多补丁协议的缺省语义。
noUncheckedIndexedAccess 会给声明但未明确列出的索引读取加入 undefined。对 Record<string, Handler> 或带字符串索引签名的字典,handlers[name] 因而迫使调用方处理缺失;对 Record<ClosedUnion, Handler> 且索引已收窄到该联合时,读取仍可保持精确。
这些选项不替代运行时验证。它们扩大静态检查覆盖面,却不知道 JSON 是否可信、对象是否来自 JavaScript,也不知道一个字符串是否经过业务允许列表。边界函数仍应从 unknown 开始,把验证后的值交给使用工具类型表达的内部契约。
验证工具类型相关改动时,应使用项目真实的 tsconfig,再补充针对严格选项的隔离测试。只在编辑器悬浮提示中查看结果不够,因为库文件版本、编译选项、重载与上下文都会改变观察到的类型。
类型层回归测试
工具类型的主要行为发生在类型检查阶段,因此测试既要包含应该编译的用法,也要包含应该被拒绝的用法。负向测试可用 @ts-expect-error 固定诊断;如果以后错误意外消失,未使用的指令本身会让测试失败。
值级示例可以使用 satisfies 检查完整性,同时保留表达式自身较精确的推断。它适合确认有限 Record 覆盖每个键,但仍不会创建运行时验证器,也不会让来自网络的字符串自动变成键联合。
库代码还应检查生成的 .d.ts。实现中一次看似局部的来源字段变化,可能通过 Pick、Omit 或 ReturnType 扩散到公开声明;声明差异能让这类变化在发布前进入评审。
一组最小回归断言应覆盖:
- 补丁类型拒绝
id、角色与审计字段。 - 公开对象的实际序列化结果不含秘密字段。
- 有限状态联合增加成员后,处理表因缺少键而无法编译。
- 精确可选属性设置下,缺省与显式
undefined符合协议约定。
这些断言分别检查类型关系与运行时数据。只保留其中一类会留下盲区:类型测试看不见秘密仍在对象中,运行时测试也不会自动发现派生签名已经变宽。
在持续集成中固定 TypeScript 与库文件版本,并让类型测试使用与生产构建相同的核心选项。版本或配置升级应作为单独变更评审,因为它可能在源码不变时改变工具类型的计算结果。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题