类型守卫(type guard) 是能让 TypeScript 在某条控制流路径上缩小值类型范围的运行时检查。
自定义守卫的返回类型是一份承诺;编译器不会证明函数体真的检查了它所声称的全部条件。
让外部数据先保持 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。
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-04instanceof Date 检查原型链,所以成立分支可以调用 toISOString()。这个证据适用于代码创建的 Date 实例;JSON 中的日期仍是字符串,必须解析后才能成为 Date。
提前返回让剩余路径自然收窄,比在每个分支里写类型断言更可靠。若以后给 Input 加入新成员,最后的数字操作会促使你重新检查分支是否完整。
验证 JSON 对象
解析 JSON 后先保留 unknown。isRecord() 只证明容器可读取,isShipment() 再检查必填字段、有限数字和允许的状态值。
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 都是字符串。
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。
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" | 不做隐式转换 |
| 非有限数字 | NaN、Infinity | typeof 仍会报告 number |
| 越界数字 | weightKg: -1 | 领域约束失败 |
| 非法判别值 | state: "waiting" | 不属于已知联合成员 |
| 额外字段 | 多一个 debug | 明确协议是允许还是拒绝 |
成功样例也不能只有一个。应覆盖每个合法判别值、边界数字和允许的可选字段组合,确认守卫没有把合法值拒之门外。真假两组测试共同约束谓词的双向契约。
测试还应通过真实调用方消费收窄结果。例如在 isShipment(value) 成立后读取每个承诺字段,或把守卫传给 filter() 并检查结果元素类型。这样既验证运行时行为,也验证签名给出的静态体验。
变更时保持同步
领域类型增加字段或联合成员时,守卫必须一起更新。最安全的代码组织方式是把类型、守卫和边界测试放在同一模块附近,并让评审把它们视为一个协议变更。
单元测试无法直接证明任意谓词完全正确,但可以锁住已知边界。再加上对外部样本的契约测试,能发现服务端字段改名、可空性变化和新判别值,而这些变化不会被本地接口自动感知。
若类型由模式或协议文件生成,守卫也应来自同一事实来源,或明确只做额外领域检查。手工复制一份接口再复制一份验证逻辑,会制造两个都能编译却彼此不一致的定义。
导出范围
不是每个局部检查都值得导出成公共守卫。只在一个分支使用的 typeof 或判别字段比较留在调用处更直观;跨多个边界复用、拥有独立测试和稳定契约的检查才适合具名导出。
公共守卫会成为 API 的一部分。修改它的接受范围既会改变运行时行为,也会改变调用方的静态收窄,因此需要像修改解析器签名一样评审。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题