本节目标:掌握
typeof、instanceof、in、真值与相等性这几类内置收窄手段,会写自定义类型守卫与断言函数,并理解控制流分析的工作原理与失效场景,从而在遇到「收窄不生效」时能自己定位原因。
7.3 类型守卫与控制流分析
判别联合的前提是数据自带判别字段。但很多场景没有这么幸运:一个参数可能是字符串也可能是数组,一个对象可能是某个类的实例也可能不是,一个接口可能有某个方法也可能没有。这时编译器无法从结构上区分,需要你在运行期做检查,并把检查结果告诉编译器。
TypeScript 把「检查动作」和「类型收窄」绑定在一起的能力,叫类型守卫(type guard);而支撑收窄在全函数范围内生效的推理机制,叫控制流分析(control flow analysis,CFA)。两者配合,才有「写一个 if (typeof x === "string") 之后,下面 x 就变成 string」的效果。
typeof:收窄原始类型
typeof 是最常用的守卫。它对原始类型几乎无死角:
function format(value: string | number | boolean): string {
if (typeof value === "string") {
return value.toUpperCase(); // value: string
}
if (typeof value === "number") {
return value.toFixed(2); // value: number
}
return value ? "是" : "否"; // value: boolean
}
需要记住的对应关系是固定的,typeof 的结果只有这几种字符串:
| 写法 | 收窄到 |
|---|---|
typeof x === "string" | string |
typeof x === "number" | number |
typeof x === "boolean" | boolean |
typeof x === "bigint" | bigint |
typeof x === "symbol" | symbol |
typeof x === "undefined" | undefined |
typeof x === "object" | object(注意:null 也会命中) |
typeof x === "function" | Function |
有两个经典陷阱必须记住。
陷阱一:typeof null === "object"。 这是 JavaScript 的历史遗留问题,TypeScript 也改不了:
function getLength(value: string | null) {
// if (typeof value === "object") { ... } // value 被收窄成 null!
if (value !== null) {
return value.length; // 先排除 null,再使用
}
return 0;
}
陷阱二:数组与对象在 typeof 下都是 "object"。 要区分数组必须用 Array.isArray,这是一个编译器能识别的收窄函数:
function sum(values: number | number[]): number {
if (Array.isArray(values)) {
return values.reduce((a, b) => a + b, 0); // values: number[]
}
return values; // values: number
}
instanceof:按类收窄
当类型来自 class 时,instanceof 是最直接的守卫。它检查的是原型链,所以只对类的实例有效:
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
class NetworkError extends Error {
constructor(public timeoutMs: number, message: string) {
super(message);
}
}
function describeError(err: ApiError | NetworkError): string {
if (err instanceof ApiError) {
return `接口错误 ${err.status}: ${err.message}`; // err: ApiError
}
return `网络超时 ${err.timeoutMs}ms`; // err: NetworkError
}
注意 instanceof 依赖运行期的构造函数对象,因此有两个限制:其一,跨 iframe / 跨 realm 的同类实例判断会失败(原型链不同);其二,对 interface 和 type 别名完全无效,因为它们在运行期不存在。后者是初学者最容易犯的错:
interface User { id: string; name: string }
function handle(x: User | string) {
// if (x instanceof User) { } // 错误:'User' only refers to a type
}
in:按属性存在性收窄
当联合的成员是对象类型、且各有不同属性时,in 是最合适的手段:
type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest"; expiresAt: Date };
function describe(user: Admin | Guest): string {
if ("permissions" in user) {
return `管理员,权限数 ${user.permissions.length}`; // user: Admin
}
return `访客,到期 ${user.expiresAt.toISOString()}`; // user: Guest
}
in 只检查属性是否存在(包括原型链上),不关心类型。它的适用边界是:属性名必须在候选成员中唯一出现,否则收窄不精确。
type A = { shared: string; a: number };
type B = { shared: string; b: number };
function f(x: A | B) {
if ("shared" in x) {
// x 仍是 A | B —— shared 两者都有,收窄不了
}
if ("a" in x) {
// x: A —— 因为只有 A 有 a
}
}
注意 role: "admin" / role: "guest" 这个例子其实同时满足判别联合的条件,此时用 switch (user.role) 会更清晰。in 更适合属性名语义完全不同、或无法事先约定判别字段的场景。
真值收窄与相等性收窄
除了上面三种「显式检查类型」,控制流分析还会利用真假判断和相等比较来收窄。
真值收窄指的是把值放进 if 条件里,编译器会排除掉所有 falsy 值:
function printName(name?: string) {
if (name) {
console.log(name.toUpperCase()); // name: string(排除了 undefined 和 "")
}
}
function firstItem(items: string[] | null) {
if (items && items.length > 0) {
return items[0]; // items: string[]
}
}
需要注意,真值收窄会连同空字符串、0、NaN 一起排除。如果业务上 "" 是合法值,就必须写成显式比较:
function printName(name?: string) {
if (name !== undefined) {
console.log(name.length); // "" 也会进来,这是对的
}
}
相等性收窄用于字面量比较与 null / undefined 判断:
type Direction = "up" | "down";
function move(dir: Direction) {
if (dir === "up") {
// dir: "up"
}
}
function trim(value: string | null | undefined): string {
if (value == null) { // 注意是双等号,同时排除 null 和 undefined
return "";
}
return value.trim(); // value: string
}
value == null 是一个被广泛使用的小技巧:双等号在 null 与 undefined 之间不做区分,因此一次判断就能排除两者。项目若启用了 eqeqeq 之类的 lint 规则,通常会对 == null 开例外。
自定义类型守卫:x is T
内置守卫覆盖不了所有情况。比如判断一个值是否是「合法的用户对象」,需要检查多个字段。这时可以写自定义守卫,语法是返回类型标注为 x is T:
interface User {
id: string;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as User).id === "string" &&
typeof (value as User).name === "string" &&
typeof (value as User).email === "string"
);
}
function greet(value: unknown) {
if (isUser(value)) {
return `你好,${value.name}`; // value: User
}
return "未知用户";
}
要点有三:
- 返回类型是
value is User而不是boolean,这是编译器识别守卫的唯一依据; - 参数类型通常写
unknown,因为守卫的职责就是从「未知」中筛出「已知」; - 函数体必须自己保证正确性——编译器不会校验你的实现是否真的检查了那些字段,写错了它照样信任你。这是类型断言的一种「伪装形式」,需要谨慎。
自定义守卫也可以带泛型,用于判断数组元素:
function isString(value: unknown): value is string {
return typeof value === "string";
}
function filterStrings(values: unknown[]): string[] {
return values.filter(isString); // filter 能识别守卫,结果推断为 string[]
}
Array.prototype.filter 的类型定义专门为守卫做了重载,所以传守卫进去会得到精确的元素类型,而传普通 boolean 函数只会得到原类型。
断言函数:asserts x is T
守卫是「返回布尔值」,断言函数则是「不通过就抛错」,常用于参数校验:
function assertIsUser(value: unknown): asserts value is User {
if (
typeof value !== "object" ||
value === null ||
typeof (value as User).id !== "string"
) {
throw new TypeError("不是合法的用户对象");
}
}
function handle(payload: unknown) {
assertIsUser(payload);
// 这一行之后,payload 的类型是 User
console.log(payload.email);
}
它的特别之处是改变了后续控制流中变量的类型,因此调用它的那条语句之后,变量就一直是收窄后的类型。这比守卫更省事,代价是控制流会中断(抛异常),不适合「想继续走另一条路」的场景。
断言函数有一个重要限制:必须显式标注类型。如果把它写成箭头函数并赋给变量,编译器无法识别:
// 错误:断言签名必须写在函数声明上
// const assertUser = (v: unknown): asserts v is User => { ... };
另外,断言函数不能是异步的(asserts 签名在 async 函数上会被拒绝),因为异步抛出无法被同步控制流感知。
控制流分析的工作原理与失效场景
理解了收窄手段后,还需要知道它什么时候会失效。控制流分析本质上是对变量在代码路径上做「类型区间传播」,它跟踪的是变量的声明,而不是值本身。这带来几类典型失效。
失效一:闭包回调中收窄丢失。 因为回调可能在任意时刻执行,编译器无法保证执行时变量还是原来的类型:
function process(value: string | undefined) {
if (value !== undefined) {
// 错误:'value' is possibly 'undefined'
// setTimeout(() => console.log(value.toUpperCase()), 0);
}
}
// 正确写法:先取到局部常量
function process2(value: string | undefined) {
const v = value;
if (v !== undefined) {
setTimeout(() => console.log(v.toUpperCase()), 0); // OK
}
}
这里的关键是 const v = value。const 声明的变量在控制流分析中被视为不可变,编译器愿意相信它不会变;而参数 value 是可变的,回调里不信任。
失效二:可变变量的收窄会被后续赋值打断。
function f(x: string | number) {
if (typeof x === "string") {
console.log(x.length); // x: string
}
x = 42; // 重新赋值
// console.log(x.length); // 错误:x 现在是 number
}
失效三:Object.keys、索引访问等无法收窄。 遍历对象键时,编译器无法把键与值的类型关联起来:
type Payload = { name: string; age: number };
function print(payload: Payload, key: keyof Payload) {
// payload[key] 的类型是 string | number,无法自动收窄
console.log(payload[key]);
}
这类问题需要泛型或重载解决,属于 8.1 泛型约束(extends) 与 9.1 节的内容。
把失效场景汇总成表,方便排查:
| 失效表现 | 根本原因 | 规避写法 |
|---|---|---|
| 回调里提示 possibly undefined | 编译器不信任可变变量 | 先赋给 const 局部变量 |
| 循环内每次都要重新判断 | 循环体中变量可能被改 | 在循环内声明 const 副本 |
收窄在 try 块外失效 | 赋值可能发生在任意分支 | 缩小变量作用域 |
payload[key] 仍是联合 | 键与值的关联无法静态推导 | 用泛型约束(第 8 章) |
| 自定义守卫不生效 | 返回类型写成了 boolean | 改为 value is T |
| 断言函数在箭头函数上不生效 | 签名丢失 | 用函数声明写法 |
与其他章节的衔接
本节讲的是「没有判别字段时怎么办」。实际项目中,最省事的做法是在设计数据结构时就主动加上判别字段,把收窄交给 7.2 判别联合 ,把守卫留给真正无法结构化的场景。这是「类型设计优先」的思路,17.1 项目结构与分层设计 在讲项目分层时会再次强调。
另外,自定义守卫与运行期校验库是互补关系:守卫适合你已经了解数据形态、想给编译器一个承诺;而边界数据(API 响应、用户输入、配置文件)更适合交给 Zod 这类库在运行期完整校验一遍,13.2 Zod 模式验证与类型推导 会展开这套做法。本站的 Zod 模式验证 与 TypeScript 类型编程 分别从运行期与类型层给出了延伸讨论,前者偏工程落地,后者偏类型系统本身的表达力。
小结
本节把「收窄」这件事拆成了两半:手段与机制。
手段上,内置守卫有 typeof(原始类型,注意 null 与数组两个陷阱)、instanceof(类实例,对 interface 无效)、in(属性存在性)、Array.isArray;隐式收窄有真值判断与相等比较,其中 value == null 是排除 null | undefined 的惯用写法。自定义手段有返回 x is T 的类型守卫和返回 asserts x is T 的断言函数,前者适合「筛出并继续」,后者适合「不合法就抛错」。
机制上,收窄靠的是控制流分析,它跟踪的是变量声明而非值。因此可变变量在闭包回调、循环、跨赋值路径中会丢失收窄,而 const 局部变量不会。记住「先 const 副本、再收窄」这条经验,能解决绝大多数「收窄不生效」的问题。
至此第 7 章结束。我们已经有了表达离散取值的字面量联合、建模互斥形态的判别联合、以及在无判别字段时主动收窄的守卫与断言。这三者构成了 TypeScript 类型安全的核心闭环——用窄类型描述数据,用收窄打通分支。下一章 8.1 泛型约束(extends) 我们将进入泛型约束,看看如何把「类型参数必须满足某条件」这件事写进签名里,让复用与安全同时成立。
阅读导航:上一节:7.2 判别联合(Discriminated Unions) · 下一节:8.1 泛型约束(extends) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。