# 工具类型

Source: https://codewiki.com/zh/typescript/utility-types/

> - **what**: 工具类型（utility type）根据已有类型派生新类型，保留字段之间的关系，避免手写多份容易漂移的对象、联合或函数契约。
> - **trap**: `Partial` 与 `Readonly` 默认只处理第一层，`Omit` 也不会删除运行时属性；这些工具改变的是静态可赋值关系，不是数据本身。
> - **fix**: 先写清允许改变、暴露或索引的集合，再选择最窄的工具类型；对外部数据仍做运行时构造或验证，并用严格编译选项检查边界。

## 是什么，为什么存在

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

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

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

工具类型不会验证数据，也不会复制、冻结或删改对象。这个边界来自类型擦除（type erasure）：编译后，`Partial` 与 `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` 的每个第一层属性标为可选 | 不会递归，也不会创建默认值 |
| `Required` | 移除每个第一层属性的可选标记 | 不会证明运行时值已存在 |
| `Readonly` | 把每个第一层属性标为只读 | 不会冻结对象或嵌套值 |
| `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` 不会去掉 `readonly`，`Readonly` 也不会自动让可选字段变为必填。因此 `Readonly<Partial>` 表达的是第一层属性既可缺省又不可重新赋值，而不是「完整且不可变」。

### 联合过滤

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

| 工具 | 对每个联合成员的规则 |
| --- | --- |
| `Exclude<T, U>` | 成员可赋给 `U` 时丢弃，否则保留 |
| `Extract<T, U>` | 成员可赋给 `U` 时保留，否则丢弃 |
| `NonNullable` | 丢弃 `null` 与 `undefined` |

这里比较的是可赋值性，而不是名义上的标签。`Extract<Shape, { kind: "circle" }>` 能筛出匹配的对象成员，是因为该成员可赋给目标形状；如果目标还要求来源成员没有的字段，结果可能是 `never`。筛选条件越具体，越要检查自己是否无意中排除了合法成员。

### 函数与构造器的形状

`Parameters` 产生参数元组，`ReturnType` 取得函数返回类型。`ConstructorParameters` 与 `InstanceType` 对构造签名做对应转换；`Awaited` 则递归取得 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` 更能表达权限边界。

<!-- quick -->

```typescript
// file: 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: "mina@example.com" },
  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);
```

```text
inv-42: Payable in 30 days
mina@example.com
```


<!-- /quick -->

对象展开创建了新账单并保留未更新字段，但这是运行时函数自己的行为，不是 `Partial` 的效果。`Partial` 只允许 `patch` 缺少第一层字段；若提交 `recipient`，仍必须提供完整的 `name` 和 `email`。

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

### 让封闭状态表保持完整

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

```typescript
// file: 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(" | "));
```

```text
draft: edit | sent: wait | overdue: remind
paid: archive | void: retain
```

这里的安全索引来自 `status: InvoiceStatus` 与有限键集合完全一致。若参数只是 `string`，`Record<InvoiceStatus, string>` 不能证明该字符串是合法状态；入口处仍需验证或收窄。

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

### 看见浅层 `Readonly`

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

```typescript
// file: 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(","));
```

```text
draft,send
draft
draft,send
```

第二种写法使数组变更在类型层被拒绝，并通过创建新数组表达状态转换。它仍不等于运行时冻结：来自 JavaScript、断言或可变别名的代码仍可能改动同一对象，所以不可信边界与共享可变状态需要独立控制。

### 复用异步函数契约

包装器用 `Parameters` 复用参数元组，用 `Awaited<ReturnType<...>>` 得到最终账单。原函数增删参数或修改返回结构时，包装器签名会随之接受检查。

```typescript
// file: 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();
```

```text
load inv-42 tax=true
inv-42: 2880
```

类型复用减少了签名重复，但不会验证实现语义。生成的包装器仍可能打乱参数、吞掉拒绝、记录秘密或改变 `this` 绑定；这些都需要运行时测试和代码评审。

## 陷阱

### 把第一层转换当作递归转换

> **陷阱:** `Partial`、`Required` 与 `Readonly` 只映射 `T` 的直接属性。嵌套对象、数组、`Date`、`Map` 和函数保留原来的类型语义。

**修复方法：** 明确列出真正需要递归的节点，优先为领域命令写专用类型。确实需要通用递归工具时，把数组、元组、函数和内建对象分别处理，并到 `typescript/custom-utility-types` 检查相应边界，不能只写一行 `T[P] extends object`。

### 把 `Omit` 当作数据脱敏

> **陷阱:** 完整对象可赋给省略字段后的结构，因此 `return account` 可以通过 `Omit<Account, "passwordHash">` 返回类型检查，同时运行时对象仍携带 `passwordHash`。

**修复方法：** 在运行时用解构或明确允许列表构造新对象，并测试 `JSON.stringify` 或实际传输载荷。秘密字段采用默认拒绝的投影；不要依赖返回类型注解、断言或额外属性检查执行删除。

### 用整个实体的 `Partial` 设计补丁

> **陷阱:** `Partial` 通常会让调用方修改 `id`、角色、所有权、审计时间和其他本应由服务控制的字段。它还把「字段可更新」与「字段在创建后是否存在」混成一件事。

**修复方法：** 先用 `Pick` 建立允许更新的字段集合，再决定哪些字段可缺省。对嵌套补丁定义明确命令并在运行时拒绝未知键；权限不同的操作使用不同补丁类型。

### 混淆缺省与显式 `undefined`

> **陷阱:** 在未启用 `exactOptionalPropertyTypes` 时，可选属性通常允许显式写入 `undefined`。补丁合并会把原值覆盖为 `undefined`，这与完全不提供该字段不同。

**修复方法：** 为 API 明确缺省、清空与设为 `undefined` 的语义，启用 `exactOptionalPropertyTypes`，并在需要清空时使用明确的 `null` 或操作标签。测试对象是否拥有键，不能只用真值判断。

### 用 `Record<string, V>` 保证任意查找存在

> **陷阱:** `Record<string, V>` 在静态上把每个字符串索引都视为 `V`，但运行时普通对象仍可能没有该键。生成代码常直接调用 `handlers[name](value)`，未知名称就会得到 `undefined` 并抛错。

**修复方法：** 键集合封闭时使用字面量联合；键集合开放时启用 `noUncheckedIndexedAccess` 并处理缺失值，或使用返回 `V | undefined` 的 `Map#get`。外部字符串先验证再索引。

### 让拼错的 `Omit` 静默通过

> **陷阱:** `Omit<T, K>` 允许 `K` 是不属于 `keyof T` 的属性键，因此拼错排除字段可能不会产生诊断。字段仍留在派生类型中，后续断言又可能掩盖这个结果。

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

<!-- deep -->

## 组合为何有效

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

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

`Required<Partial>` 不一定恢复原始 `T` 的全部语义。它会让第一层键重新必填，但编译选项与原始可选属性的值类型会影响是否仍含 `undefined`；嵌套层也从未改变。不要把看似相反的工具当作通用可逆运算。

类型别名只保存计算规则，不会保存某一版本的字段快照。来源类型变化后，派生结果会立即变化，这既是价值也是风险。公开边界需要声明输出差异、类型测试或 API 评审，避免新增内部字段自动泄漏到基于 `Omit` 的外部契约。

## 分配、`never` 与过滤结果

分配条件类型逐个处理联合成员。概念上，`Exclude` 会计算 `Exclude<A, U> | Exclude<B, U>`；被排除的分支产生 `never`，而 `never` 在联合中消失。`Extract` 只是交换保留与丢弃分支。

这一机制解释了为什么对象联合可以按形状过滤，也解释了意外的空结果。若每个成员都可赋给排除目标，`Exclude` 得到 `never`；若没有成员满足提取目标，`Extract` 也得到 `never`。可用赋值测试或 `satisfies` 固定预期成员，避免空类型在更远处才暴露。

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

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

## 编译选项补全契约

`strict` 是起点，但工具类型常受更具体的选项影响。`exactOptionalPropertyTypes` 把 `property?: T` 更精确地解释为「属性可缺省」，不会自动允许赋值 `undefined`，除非 `T` 本身包含它。这让 `Partial` 更接近许多补丁协议的缺省语义。

`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 与库文件版本，并让类型测试使用与生产构建相同的核心选项。版本或配置升级应作为单独变更评审，因为它可能在源码不变时改变工具类型的计算结果。

<!-- /deep -->

[检查点: typescript/utility-types](https://codewiki.com/zh/typescript/utility-types/#checkpoint)

## 延伸阅读

- [TypeScript Handbook：Utility Types](https://www.typescriptlang.org/docs/handbook/utility-types.html)
- [TypeScript Handbook：Mapped Types](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html)
- [TypeScript Handbook：Conditional Types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html)
- [TypeScript TSConfig：exactOptionalPropertyTypes](https://www.typescriptlang.org/tsconfig/exactOptionalPropertyTypes.html)
- [TypeScript TSConfig：noUncheckedIndexedAccess](https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html)
