工具类型

用 Partial、Pick、Omit、Record 与函数工具类型派生契约,并避开浅层转换、运行时擦除和过宽键集合。

难度 进阶 时长 标准深度约 16分钟
版本 TypeScript 6
what

工具类型(utility type) 根据已有类型派生新类型,保留字段之间的关系,避免手写多份容易漂移的对象、联合或函数契约。

trap

PartialReadonly 默认只处理第一层,Omit 也不会删除运行时属性;这些工具改变的是静态可赋值关系,不是数据本身。

fix

先写清允许改变、暴露或索引的集合,再选择最窄的工具类型;对外部数据仍做运行时构造或验证,并用严格编译选项检查边界。

是什么,为什么存在

TypeScript 工具类型是一组由标准库提供的泛型类型别名。它们接收一个或多个类型,再产生新类型,例如把现有对象的一部分字段变为可选、选择公开字段、过滤联合成员,或取得函数的参数元组。结果只供类型检查器使用,不会生成 JavaScript 函数或对象。

工具类型解决的是「相关契约如何保持同步」。若 Invoice 增加字段,手写的 InvoicePreviewInvoicePatch 和状态索引可能悄悄落后;用 PickPartialRecord 表达来源和转换后,编译器可以在来源变化时重新计算派生类型。代码评审看到的也不再只是两个相似接口,而是二者之间的规则。

你会在更新命令、公开响应、状态处理表、函数包装器和联合过滤器中遇到这些类型。它们最适合派生关系确实属于领域契约的场景;若两个形状只是今天碰巧相似,独立命名通常更诚实,因为未来变化未必应该联动。

工具类型不会验证数据,也不会复制、冻结或删改对象。这个边界来自 类型擦除(type erasure) :编译后,Partial<Account>Omit<Account, "passwordHash"> 都不存在。涉及网络输入、权限字段或秘密值时,必须另写运行时代码完成检查和投影。

按意图选择

意图常用工具关键问题
改变属性修饰符PartialRequiredReadonly只需要第一层,还是嵌套层也要改变?
选择对象字段PickOmit允许列表与排除列表中,哪个在未来更安全?
建立键到值的映射Record键是封闭联合,还是任意运行时字符串?
过滤联合成员ExcludeExtractNonNullable判断依据是可赋值关系,而非字段名称吗?
复用函数形状ParametersReturnTypeAwaited函数是否有重载或泛型关系?

Pick 是允许列表:只有列出的字段进入结果,适合公开 DTO 和受限更新。Omit 是排除列表:来源新增字段时,新字段会自动进入结果,适合「除少数基础设施字段外都保留」的内部转换。这个差异不是风格问题,而是来源扩展时默认允许还是默认拒绝的策略。

来源变化如何传播

派生类型把来源变化变成编译期反馈,但不同工具的反馈方向不同。评审一个公共类型时,应先模拟来源增加、删除和修改字段,再决定自动传播是否符合兼容性策略。

来源变化Pick<T, K>Omit<T, K>
新增字段默认不进入结果默认进入结果
删除被点名字段K 约束产生错误排除名可静默失效
修改保留字段的值类型传播到结果传播到结果
修改保留字段的修饰符传播到结果传播到结果

对外响应通常更希望来源新增字段时默认不公开,所以 PickOmit 稳妥。内部持久化准备步骤可能恰好相反:只删除 id 和时间戳后,其余领域字段都应继续传播,此时 Omit 更直接。

PartialRequiredReadonly 会覆盖整组第一层键的某个修饰维度。若业务规则只适用于部分字段,应先缩小键集合,或者把需要不同规则的字段分别派生后再组合,避免把技术便利误写成领域权限。

自动传播不能替代版本判断。来源字段的类型从 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> 不会去掉 readonlyReadonly<T> 也不会自动让可选字段变为必填。因此 Readonly<Partial<T>> 表达的是第一层属性既可缺省又不可重新赋值,而不是「完整且不可变」。

联合过滤

ExcludeExtractNonNullable 建立在 条件类型(conditional type) 上。对于裸类型参数表示的联合,条件会分配到每个成员,再把各成员的结果重新组成联合。这就是 Exclude<"draft" | "paid", "paid"> 得到 "draft" 的原因。

工具对每个联合成员的规则
Exclude<T, U>成员可赋给 U 时丢弃,否则保留
Extract<T, U>成员可赋给 U 时保留,否则丢弃
NonNullable<T>丢弃 nullundefined

这里比较的是可赋值性,而不是名义上的标签。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

重载函数需要额外注意。条件类型从多个调用签名推断时使用最后一个签名,通常是最宽的实现兼容签名,因此 ParametersReturnType 未必保留每个重载之间的精确对应关系。包装器若必须保留重载,应显式写出公开重载或重新设计成可辨识参数联合。

静态结构与运行时值

TypeScript 使用 结构类型(structural typing) :值只要拥有所需结构,就可赋给派生类型。一个完整 Account 值因此可以赋给 Omit<Account, "passwordHash">,因为目标只是不再要求那个字段,并没有禁止额外字段。

对象字面量在某些直接赋值位置会接受额外属性检查,但先保存到变量、从函数返回或经过断言后,行为可能不同。不能把这种检查当作「对象中绝无额外属性」的证明。若响应必须不含秘密,应构造一个只包含允许字段的新对象,并对最终序列化结果测试。

示例

下面四个示例围绕账单领域逐步使用选择、修饰、联合过滤、只读与函数形状工具。所有输出均由本地 npx tsx 实际执行得到,源码还通过了 TypeScript 6.0.3 的严格检查。

派生最小更新契约

先用 Pick 明确允许修改的字段,再用 Partial 允许调用方只提交其中一部分。idstatus 从未进入更新类型,比直接写 Partial<Invoice> 更能表达权限边界。

patch-invoice.ts
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,仍必须提供完整的 nameemail

这个设计也没有执行运行时验证。若补丁来自 JSON,应先从 unknown 检查允许字段、值类型与未知字段策略,再调用 applyPatch。类型注解只约束已进入 TypeScript 信任边界的数据。

让封闭状态表保持完整

ExcludeExtract 从同一状态联合派生活跃与终态集合,Record 则要求每个账单状态都有后续动作。新增状态后,动作表缺少对应键会产生编译错误。

status-actions.ts
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 与有限键集合完全一致。若参数只是 stringRecord<InvoiceStatus, string> 不能证明该字符串是合法状态;入口处仍需验证或收窄。

使用 Record<string, string> 会表达另一份契约:任何字符串键都被视为有字符串值。普通 JavaScript 对象并不自动满足这个运行时承诺,因此动态字典通常要配合 noUncheckedIndexedAccess、显式缺失检查,或者改用 Map

看见浅层 Readonly

Readonly<QueueState> 阻止给 namejobs 属性重新赋值,却没有改变 jobs 自身的数组类型。要让快照的数组也只读,必须在源契约中把它声明为 readonly string[],或使用经过领域设计的递归类型。

readonly-queue.ts
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<...>> 得到最终账单。原函数增删参数或修改返回结构时,包装器签名会随之接受检查。

load-invoice.ts
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 | undefinedMap#get。外部字符串先验证再索引。

让拼错的 Omit 静默通过

修复方法: 对安全敏感的排除字段增加负向类型测试,或定义 StrictOmit<T, K extends keyof T> = Omit<T, K>。公开响应更适合使用 Pick 允许列表,并同时验证运行时序列化结果。

深入 组合为何有效

组合为何有效

工具类型的结果仍是普通类型,因此可以继续作为另一个工具的输入。Partial<Pick<Invoice, "note" | "recipient">> 先限制字段集合,再改变修饰符;从内向外阅读可以看清每一步。为复杂组合命名中间类型,通常比堆叠四层尖括号更容易评审。

组合顺序有时改变结果。先 PickPartial 与先 PartialPick 在简单对象上通常等价,但「允许哪些字段」应优先出现在命名与评审流程中。对于条件类型、键重映射或互相冲突的修饰符,不能假设交换顺序仍相同,应写类型测试证明期望的可赋值关系。

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 固定预期成员,避免空类型在更远处才暴露。

分配只发生在被检查一侧是裸类型参数的特定形式。自定义条件类型把参数包进单元素元组后,可以把联合整体比较并阻止分配。这个细节属于自定义工具类型设计;使用内建 ExcludeExtract 时,应按逐成员过滤理解。

NonNullable<T> 也是过滤,而不是运行时空值检查。函数返回 NonNullable<T> 之前必须通过控制流检查、解析或构造证明值非空;使用 as NonNullable<T> 只是在关闭诊断,不能改变 nullundefined

编译选项补全契约

strict 是起点,但工具类型常受更具体的选项影响。exactOptionalPropertyTypesproperty?: 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。实现中一次看似局部的来源字段变化,可能通过 PickOmitReturnType 扩散到公开声明;声明差异能让这类变化在发布前进入评审。

一组最小回归断言应覆盖:

  • 补丁类型拒绝 id、角色与审计字段。
  • 公开对象的实际序列化结果不含秘密字段。
  • 有限状态联合增加成员后,处理表因缺少键而无法编译。
  • 精确可选属性设置下,缺省与显式 undefined 符合协议约定。

这些断言分别检查类型关系与运行时数据。只保留其中一类会留下盲区:类型测试看不见秘密仍在对象中,运行时测试也不会自动发现派生签名已经变宽。

在持续集成中固定 TypeScript 与库文件版本,并让类型测试使用与生产构建相同的核心选项。版本或配置升级应作为单独变更评审,因为它可能在源码不变时改变工具类型的计算结果。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 基础类型泛型
下一篇 Mapped types 即将上线 Conditional types 即将上线 Custom utility types 即将上线 satisfies 操作符
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?