# 类型守卫

Source: https://codewiki.com/zh/typescript/type-guards/

> - **what**: 类型守卫（type guard）是能让 TypeScript 在某条控制流路径上缩小值类型范围的运行时检查。
> - **trap**: 自定义守卫的返回类型是一份承诺；编译器不会证明函数体真的检查了它所声称的全部条件。
> - **fix**: 让外部数据先保持 `unknown`，逐项验证容器、必填字段与领域约束，再把收窄后的值交给业务代码。

## 是什么，为什么存在

TypeScript 的静态类型在程序运行前工作，但 JavaScript 的值仍会在运行时从网络、文件、消息和无类型调用方进入。一个声明为 `string | number` 的参数也只能安全使用两种成员共有的操作。代码需要提供证据，才能执行只属于其中一种类型的操作。

类型守卫就是这份证据。`typeof value === "string"` 不只计算一个布尔值；TypeScript 还会在成立分支中把 `value` 视为 `string`。这种根据分支、赋值和可达性持续更新「当前可能类型」的过程叫作控制流收窄（control-flow narrowing）。

守卫没有给 JavaScript 加入新的运行机制。`typeof`、`instanceof`、`in`、相等比较和属性比较都照常运行，编译器只是理解其中一部分表达式的含义。接口和联合类型会被类型擦除（type erasure），因此外部输入仍然需要真实的运行时检查。

你会在处理 `unknown`、可空值、联合类型、解析后的 JSON 和回调参数时使用守卫。守卫适合回答「这个值现在是否满足某个类型」；如果失败必须立即终止，则用断言函数表达更清楚。

## 工作原理

每个变量都有声明类型，也有控制流当前位置的观察类型。声明类型决定以后允许赋入哪些值，观察类型则描述沿当前路径还能到达这里的值。一次检查可以缩小观察类型，但不会改写原来的声明。

### 编译器识别的证据

TypeScript 能识别常见 JavaScript 检查，也能沿短路表达式和提前返回传播结果。下面的形式覆盖大多数日常守卫；它们检查的运行时事实并不相同。

| 检查 | 适合证明 | 需要留意 |
| --- | --- | --- |
| `typeof value === "string"` | 原始类型与函数 | `typeof null` 是 `"object"` |
| `value instanceof Error` | 原型链中的构造函数 | JSON 对象和跨 realm 对象未必通过 |
| `"id" in value` | 属性存在于对象或其原型链 | 不验证属性值，也不限定为自有属性 |
| `value === null` | 精确值或另一变量的类型关系 | `== null` 同时覆盖 `null` 与 `undefined` |
| `result.kind === "ok"` | 具有字面量判别字段的联合成员 | 输入本身仍须可信 |
| `isShipment(value)` | 自定义运行时条件 | 编译器信任谓词签名 |

`typeof` 最适合区分字符串、数字、布尔值、`bigint`、`symbol`、`undefined`、函数和宽泛对象。对象分支必须单独排除 `null`；数组应使用 `Array.isArray()`，类实例可以使用 `instanceof`。这些检查给出的类型不会比运行时证据更具体。

`in` 检查属性名是否能在对象上找到，包括原型链上的属性。用于联合类型时，成立分支保留具有必填或可选该属性的成员，不成立分支保留缺少该属性或只把它声明为可选的成员。若输入是 `unknown`，应先证明它是非空对象，再使用 `in`。

字面量判别字段通常比零散的属性探测更稳定。若每个联合成员都带有 `kind`、`status` 或 `type`，比较这个字段就能收窄整个对象及其载荷。新增成员时，再配合 `never` 做穷尽性检查，遗漏分支会变成编译错误。

### 自定义谓词与断言

内置检查无法为复杂对象命名时，函数可以返回类型谓词（type predicate），例如 `value is Shipment`。调用方在真分支中得到 `Shipment`，在假分支中也会排除相应类型。函数体必须承担这份双向含义，编译器只检查谓词类型是否能赋给参数类型，不会替你验证实现逻辑。

断言函数（assertion function）使用 `asserts value is Type` 或 `asserts condition`。它正常返回时，后续代码得到收窄结果；条件不满足时，函数应抛出异常或以其他方式永不返回。它适合不可恢复的配置错误和内部不变量，不适合把普通无效输入变成意外异常。

类型谓词和断言函数都不同于类型断言（type assertion）。`value as Shipment` 只改变检查器的看法，不执行检查，也不转换值。只要数据来自类型系统之外，`as` 就不能替代边界验证。

## 示例

下面四个程序由本地 `tsx` 实际执行，并在 `strict` 模式下完成类型检查。它们依次展示内置守卫、对象结构验证、数组谓词和断言函数。

### 原始值与类实例

第一个例子通过提前返回逐步排除联合成员。走到最后一行时，字符串和 `Date` 已被排除，因此只剩 `number`。

<!-- quick -->

```typescript
// file: builtin-guards.ts
type Input = string | number | Date;

function describe(value: Input): string {
  if (typeof value === "string") {
    return `text:${value.trim().toUpperCase()}`;
  }

  if (value instanceof Date) {
    return `date:${value.toISOString().slice(0, 10)}`;
  }

  return `number:${value.toFixed(1)}`;
}

const inputs: Input[] = ["  ready ", 12.25, new Date("2026-09-04T00:00:00Z")];

for (const input of inputs) {
  console.log(describe(input));
}
```

```text
text:READY
number:12.3
date:2026-09-04
```

<!-- /quick -->

`instanceof Date` 检查原型链，所以成立分支可以调用 `toISOString()`。这个证据适用于代码创建的 `Date` 实例；JSON 中的日期仍是字符串，必须解析后才能成为 `Date`。

提前返回让剩余路径自然收窄，比在每个分支里写类型断言更可靠。若以后给 `Input` 加入新成员，最后的数字操作会促使你重新检查分支是否完整。

### 验证 JSON 对象

解析 JSON 后先保留 `unknown`。`isRecord()` 只证明容器可读取，`isShipment()` 再检查必填字段、有限数字和允许的状态值。

```typescript
// file: shipment-guard.ts
type Shipment = {
  id: string;
  state: "packed" | "sent";
  weightKg: number;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function isShipment(value: unknown): value is Shipment {
  return (
    isRecord(value) &&
    typeof value.id === "string" &&
    (value.state === "packed" || value.state === "sent") &&
    typeof value.weightKg === "number" &&
    Number.isFinite(value.weightKg) &&
    value.weightKg >= 0
  );
}

function decodeShipment(raw: string): Shipment | undefined {
  const value: unknown = JSON.parse(raw);
  return isShipment(value) ? value : undefined;
}

for (const raw of [
  '{"id":"PK-42","state":"sent","weightKg":2.5}',
  '{"id":"PK-43","state":"waiting","weightKg":"2.5"}',
]) {
  const shipment = decodeShipment(raw);
  console.log(shipment ? `${shipment.id}:${shipment.state}` : "rejected");
}
```

```text
PK-42:sent
rejected
```

第二条记录同时包含非法状态和字符串重量，所以守卫返回 `false`。调用方不需要重复字段检查；只有通过同一条边界的值才获得 `Shipment` 类型。

`typeof value.weightKg === "number"` 仍会接受 `NaN` 和无穷大。示例继续使用 `Number.isFinite()` 并检查非负约束，因为领域类型的承诺应覆盖业务代码实际依赖的条件。

这个守卫允许额外字段，因为它验证的是 `Shipment` 所需的最小结构。若协议禁止未知字段，应显式比较键集合；不要把「结构足够」和「结构完全相等」混为一谈。

### 把谓词带入数组过滤

数组方法可以消费类型谓词。显式返回 `job is AssignedJob` 后，`filter()` 的结果不再是普通 `Job[]`，其中每个元素的 `owner` 都是字符串。

```typescript
// file: guarded-filter.ts
type Job = {
  id: number;
  owner?: string;
};

type AssignedJob = Job & { owner: string };

function hasOwner(job: Job): job is AssignedJob {
  return typeof job.owner === "string" && job.owner.trim().length > 0;
}

const jobs: Job[] = [
  { id: 101, owner: "Mina" },
  { id: 102 },
  { id: 103, owner: "" },
];

const assigned = jobs.filter(hasOwner);

for (const job of assigned) {
  console.log(`${job.id}:${job.owner.toUpperCase()}`);
}
```

```text
101:MINA
```

谓词检查的不只是属性存在，还排除了空字符串和纯空白字符串。`AssignedJob` 把调用方真正获得的保证写进类型，后续无需非空断言。

若回调只返回普通 `boolean`，某些简单表达式可以由 TypeScript 推断为谓词，但复杂条件未必能推断。公共辅助函数显式写出谓词有助于审查承诺，前提是测试同时覆盖真分支和假分支。

### 失败即中止的断言函数

端口无效时，调用方无法继续启动服务，因此断言比返回布尔值更贴合控制流。正常返回后，`port` 被收窄为 `number`。

```typescript
// file: assert-port.ts
function assertPort(value: unknown): asserts value is number {
  if (
    typeof value !== "number" ||
    !Number.isInteger(value) ||
    value < 1 ||
    value > 65_535
  ) {
    throw new TypeError("port must be an integer from 1 to 65535");
  }
}

function startServer(port: unknown): string {
  assertPort(port);
  return `listening:${port}`;
}

for (const candidate of [443, "443", 70_000]) {
  try {
    console.log(startServer(candidate));
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.log(`rejected:${message}`);
  }
}
```

```text
listening:443
rejected:port must be an integer from 1 to 65535
rejected:port must be an integer from 1 to 65535
```

断言检查了整数和范围，而不只是 `number`。这说明类型收窄与领域验证是两层工作：静态类型只能表达这里的 `number`，运行时函数还负责端口约束。

捕获变量默认按 `unknown` 处理更安全，因为 JavaScript 可以抛出任何值。`error instanceof Error` 保留标准错误消息，后备分支则处理字符串、对象或其他抛出值。

## 陷阱

> **陷阱:** 自定义谓词写成 `value is User`，函数体却只检查「非空对象」，会让任意对象获得并不存在的字段。

**修复：** 从 `unknown` 开始，逐项检查业务代码将读取的字段及其领域约束。给守卫准备有效值、缺字段、错类型、`null`、数组和边界数字等测试，不要只测试一个成功样例。

> **陷阱:** 谓词如果只给出充分条件而不是双向判定，假分支也可能被错误收窄。例如「是小数字」返回 `value is number`，却会在大数字上返回 `false`。

**修复：** 让 `x is T` 当且仅当 `x` 属于 `T` 时成立。只想筛选更小的业务子集时，定义能准确表达该子集的类型，或者返回普通 `boolean`，不要让假分支排除整个 `T`。

> **陷阱:** `JSON.parse(text) as User` 看起来消除了错误，却没有执行一条运行时检查。缺失字段、`null` 和错误的原始类型会原样进入业务代码。

**修复：** 把解析结果立即放进 `unknown`，然后调用守卫、解析器或模式验证器。类型断言只适用于代码已经拥有但编译器无法表达的证据，并且这份证据应能由测试说明。

> **陷阱:** `typeof value === "object"` 仍包含 `null`，而 `if (value)` 会顺带排除 `""`、`0` 和 `false`。这两种宽泛检查经常让合法的空值落入错误分支。

**修复：** 按需求检查精确条件，例如先写 `value !== null && typeof value === "object"`，或用 `value !== undefined` 只排除缺失值。只有业务规则确实把所有假值视为缺失时，才使用真值检查。

> **陷阱:** `"id" in value` 只证明属性能在对象或原型链上找到；它不证明 `id` 是自有属性、字符串或非 `undefined`。把存在性等同于完整验证会制造过宽的守卫。

**修复：** 先确认非空对象，再读取并检查属性值。协议要求自有属性时使用 `Object.hasOwn()`；属性若有数字范围、字符串格式或联合值限制，也要逐项验证。

> **陷阱:** `instanceof` 依赖运行时构造函数和原型链，因此反序列化对象、另一个 realm 创建的对象或重复加载库产生的实例可能失败。接口本身在运行时不存在，不能放在 `instanceof` 右侧。

**修复：** 只为真正由同一运行时构造函数创建的实例使用 `instanceof`。数据传输对象应检查判别字段和结构，并在需要类行为时显式构造领域实例。

<!-- deep -->

## 类型谓词是一份双向契约

显式谓词 `parameter is Type` 会同时影响条件的两个出口。返回 `true` 时，参数被收窄到 `Type`；返回 `false` 时，当前联合会排除 `Type`。所以谓词不能只描述「这次愿意接受的值」，它必须准确描述返回值与类型成员之间的关系。

假设参数类型是 `string | number`，函数只在绝对值小于十的数字上返回 `true`，却声明 `value is number`。真分支没有问题，但假分支仍可能收到 `100`；检查器会把它错误地当作 `string`。这种缺陷通常躲在成功样例之后，只有负向测试会暴露。

更窄的业务概念若能由类型表达，可以为它建立带判别字段的领域类型，再写准确守卫。若概念只是数值范围，而普通 `number` 无法携带这份证明，就让函数返回 `boolean`，或在边界创建经过验证的品牌类型。不要用一个宽泛谓词替范围检查冒充完整类型关系。

TypeScript 可以为某些简单函数推断类型谓词，例如参数未经修改且唯一返回表达式直接完成收窄的情况。推断减少重复注解，却没有免除语义审查；一旦条件变复杂或用于公共边界，显式签名和负向测试往往更容易维护。

### 组合守卫时保留证据

守卫可以通过 `&&` 逐层组合，因为右侧只在左侧成立时求值。先证明 `value` 是非空对象，右侧才能安全读取字段；先证明字段是字符串，后面才能检查长度或格式。这个顺序同时服务运行时安全和编译器分析。

通过 `||` 组合两个谓词时，结果类型应是两者的联合。若两个函数的假分支并不精确，组合后的排除会进一步放大错误。审查组合守卫时，应把每条短路路径都当成独立证明，而不是只看最终返回类型。

通用的 `hasProperty()` 可以证明某个键存在并得到 `Record<K, unknown>`，但它不能凭空知道值类型。下一步仍要检查 `record[key]`。把「属性存在」与「属性有效」拆成两层，通常比在一个泛型断言中隐藏转换更容易审核。

## 收窄、别名与可变状态

控制流分析跟踪变量和可达路径，不是一个完整的运行时所有权系统。重新给变量赋值后，观察类型会根据新值更新；修改用于判别的属性，也会破坏原来分支所依赖的事实。声明类型仍决定这次赋值是否合法。

别名让问题更隐蔽。一个守卫可以确认对象的 `profile.name` 是字符串，随后另一个持有同一对象引用的函数却把它改成 `undefined`。类型系统无法证明任意函数调用的全部副作用，因此守卫后的可变共享对象仍需要所有权规则、只读接口或防御性复制。

异步边界也需要同样审查。守卫在 `await` 之前成立，不代表外部共享状态在恢复执行时没有变化。把已经验证的原始值复制到局部常量，或把输入转换成不可变领域对象，可以让后续代码依赖稳定快照。

闭包会延长变量的使用路径。如果回调晚于创建它的分支执行，应检查捕获的是稳定常量，还是以后会重新赋值的可变变量。编译器能证明一部分最后赋值场景，但这不是并发或生命周期保证。

### 判别字段与穷尽性

可辨识联合把收窄证据放进数据本身。每个成员共享一个字面量字段，而且每个字面量只属于一个成员；检查该字段后，载荷会与状态一起收窄。这比根据多个可选字段猜测状态更容易扩展。

穷尽性检查通常在 `switch` 的剩余分支中把值赋给 `never`，或把它传给接受 `never` 的函数。新增联合成员后，旧代码的剩余值不再是 `never`，编译器就会指出缺失分支。这个保证只覆盖静态联合；未经验证的 JSON 仍可能带着非法判别值到达运行时。

如果协议由外部系统控制，边界守卫必须先验证判别字段属于已知字面量集合，再检查对应载荷。仅凭 `kind in value` 不能建立成员与字段之间的关联。一个准确的解析器应返回有效联合或结构化错误，而不是把半验证对象断言成整个联合。

## 手写守卫与模式验证器的边界

小而稳定的对象适合手写守卫，尤其是字段少、错误只需区分接受与拒绝时。守卫代码和领域类型应放在一起，并用测试锁定二者的关系。重复读取几十个字段或维护深层递归结构时，手写实现很快会与类型漂移。

模式验证器适合共享协议、嵌套对象、详细错误路径和需要复用规则的边界。选择具体库前应确认它支持目标运行时、所需语义和 TypeScript 版本；不要在示例里假定一个未安装依赖。无论使用哪种工具，静态类型都应由真实验证结果产生，而不是另写一个可能漂移的接口。

验证深度取决于边界契约。仅检查页面当前会读取的字段可能适合内部适配器，但公开 API 往往还需要拒绝未知字段、验证数组每个元素并限制字符串或数字范围。把这些选择写进解析函数名称、返回类型和测试，避免一个模糊的 `isValid()` 承担互相冲突的含义。

守卫返回布尔值，通常不解释失败位置。表单、配置文件和批量导入需要展示多个错误时，返回可辨识的成功或失败结果会比堆叠断言函数更合适。类型收窄仍然可以发生在结果的判别字段上，同时错误分支保留足够的诊断信息。

## 设计可审查的守卫 API

守卫名称应说明它证明什么。`isRecord()`、`isShipment()` 和 `hasOwner()` 分别承诺容器、完整领域对象和带所有者的子集；模糊的 `isValid()` 无法告诉调用方验证范围，也让测试难以命名。

参数应尽量接受 `unknown`，而不是先要求调用方断言成目标类型。返回类型要与实际检查精度一致；只验证部分结构的函数可以返回 `value is Record<"id", unknown>`，不应提前承诺完整对象。

边界函数还要决定失败策略。交互式输入通常需要收集错误，协议解码可以返回结果联合，不可恢复的启动配置才适合抛出。把三种行为都塞进一个布尔守卫，会迫使调用方猜测失败原因和恢复方式。

### 最小反例矩阵

守卫测试需要从它声称的类型反推反例，而不是照着实现逐行复制条件。对一个带字符串标识、状态字面量和非负有限重量的运输对象，至少应覆盖下表中的输入。

| 输入类别 | 示例 | 要验证的失败原因 |
| --- | --- | --- |
| 非对象 | `null`、字符串、数字 | 容器不可读取 |
| 错容器 | 数组、日期实例 | 结构语义不符 |
| 缺字段 | 没有 `id` | 必填字段不存在 |
| 错类型 | `weightKg: "2.5"` | 不做隐式转换 |
| 非有限数字 | `NaN`、`Infinity` | `typeof` 仍会报告 `number` |
| 越界数字 | `weightKg: -1` | 领域约束失败 |
| 非法判别值 | `state: "waiting"` | 不属于已知联合成员 |
| 额外字段 | 多一个 `debug` | 明确协议是允许还是拒绝 |

成功样例也不能只有一个。应覆盖每个合法判别值、边界数字和允许的可选字段组合，确认守卫没有把合法值拒之门外。真假两组测试共同约束谓词的双向契约。

测试还应通过真实调用方消费收窄结果。例如在 `isShipment(value)` 成立后读取每个承诺字段，或把守卫传给 `filter()` 并检查结果元素类型。这样既验证运行时行为，也验证签名给出的静态体验。

### 变更时保持同步

领域类型增加字段或联合成员时，守卫必须一起更新。最安全的代码组织方式是把类型、守卫和边界测试放在同一模块附近，并让评审把它们视为一个协议变更。

单元测试无法直接证明任意谓词完全正确，但可以锁住已知边界。再加上对外部样本的契约测试，能发现服务端字段改名、可空性变化和新判别值，而这些变化不会被本地接口自动感知。

若类型由模式或协议文件生成，守卫也应来自同一事实来源，或明确只做额外领域检查。手工复制一份接口再复制一份验证逻辑，会制造两个都能编译却彼此不一致的定义。

### 导出范围

不是每个局部检查都值得导出成公共守卫。只在一个分支使用的 `typeof` 或判别字段比较留在调用处更直观；跨多个边界复用、拥有独立测试和稳定契约的检查才适合具名导出。

公共守卫会成为 API 的一部分。修改它的接受范围既会改变运行时行为，也会改变调用方的静态收窄，因此需要像修改解析器签名一样评审。

<!-- /deep -->

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

## 延伸阅读

- [TypeScript Handbook：Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
- [TypeScript Handbook：Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
- [TypeScript Handbook：Discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions)
