《TypeScript编程入门》7.3 类型守卫与控制流分析

本节讲解当数据结构本身没有判别字段时,如何通过运行期检查把联合类型收窄到具体成员。内容涵盖 typeof、instanceof、in 运算符、真值判断与相等比较这几类内置收窄手段,以及自定义类型守卫和断言函数的写法与适用边界。我们还会深入控制流分析的机制,解释为什么收窄在闭包回调里会失效,并给出可落地的规避写法。读完后你能为任意联合类型选择合适的收窄方式,并能在收窄失效时自行定位原因。

本节目标:掌握 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 "未知用户";
}

要点有三:

  1. 返回类型是 value is User 而不是 boolean,这是编译器识别守卫的唯一依据;
  2. 参数类型通常写 unknown,因为守卫的职责就是从「未知」中筛出「已知」;
  3. 函数体必须自己保证正确性——编译器不会校验你的实现是否真的检查了那些字段,写错了它照样信任你。这是类型断言的一种「伪装形式」,需要谨慎。

自定义守卫也可以带泛型,用于判断数组元素:

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) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes