类型守卫

用运行时证据安全地收窄联合类型与 unknown,并写出可信的自定义类型谓词和断言函数。

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

类型守卫(type guard) 是能让 TypeScript 在某条控制流路径上缩小值类型范围的运行时检查。

trap

自定义守卫的返回类型是一份承诺;编译器不会证明函数体真的检查了它所声称的全部条件。

fix

让外部数据先保持 unknown,逐项验证容器、必填字段与领域约束,再把收窄后的值交给业务代码。

是什么,为什么存在

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

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

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

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

工作原理

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

编译器识别的证据

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

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

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

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

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

自定义谓词与断言

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

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

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

示例

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

原始值与类实例

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

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:READY
number:12.3
date:2026-09-04

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

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

验证 JSON 对象

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

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");
}
PK-42:sent
rejected

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

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

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

把谓词带入数组过滤

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

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()}`);
}
101:MINA

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

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

失败即中止的断言函数

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

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}`);
  }
}
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 保留标准错误消息,后备分支则处理字符串、对象或其他抛出值。

陷阱

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

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

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

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

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

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

深入 类型谓词是一份双向契约

类型谓词是一份双向契约

显式谓词 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"不做隐式转换
非有限数字NaNInfinitytypeof 仍会报告 number
越界数字weightKg: -1领域约束失败
非法判别值state: "waiting"不属于已知联合成员
额外字段多一个 debug明确协议是允许还是拒绝

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

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

变更时保持同步

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

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

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

导出范围

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

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

延伸阅读

检查点

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

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?