Menu

TypeScript 类型收窄:控制流分析如何工作

类型收窄是指 TypeScript 在 if、switch 中或提前返回之后,把 string | number 这样的宽类型变成一个具体类型。本文汇总了所有收窄方式:typeof、真值判断、相等判断、in、instanceof、赋值、类型谓词和可辨识联合。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

类型收窄是指 TypeScript 根据代码已经做过的检查,在代码的某个位置为一个值推算出更具体的类型。一个 string | number 参数在 if (typeof x === "string") 里会变成 string,在 else 里会变成 number。

如果在检查之前调用 value.toFixed(2),就会产生编译错误,因为 string 上没有 toFixed。检查是普通的 JavaScript,在运行时执行;收窄则是编译器读取这个检查并调整类型。不会额外生成任何代码。

控制流分析

TypeScript 会跟踪函数中的每一条路径:if/else、提前的 return 和 throw、switch、循环,以及短路运算符 &&、||、?? 和 ?:。在每个位置,变量的类型就是那里仍然可能出现的类型。

提前返回的写法(“卫语句”)是最易读的收窄方式:先处理特殊情况,函数剩下的部分就可以使用干净的类型。

所有收窄方式

形式示例收窄范围
typeoftypeof x === "string"原始类型和函数
真值判断if (x)去掉 null、undefined 和假值字面量
相等判断x === "a"、x == null、x !== undefined字面量、null、undefined
in"swim" in pet按属性区分对象联合类型
instanceoferr instanceof TypeError类的实例
Array.isArrayArray.isArray(x)区分数组和其他类型
赋值x = 5收窄为所赋值的类型
类型谓词function isUser(x: unknown): x is User任何你能检查的东西
断言函数function assertUser(x: unknown): asserts x is User调用之后的所有代码
可辨识属性switch (shape.kind)带标签的联合类型

最后三种在类型守卫和可辨识联合页面中讲解,其余的见下文。

真值收窄

if (x) 会去掉 null 和 undefined(以及 false、0、"" 这些字面量类型)。它写起来很短,但有一个经典陷阱:0 和 "" 都是假值,所以合法的值会被当成缺失。

对于数字和字符串,请显式地与 undefined 或 null 比较(或者使用 ??)。对象、数组和函数永远不是假值,对它们用真值判断没有问题。

相等收窄

===、!==、== 和 != 会同时收窄两边。与字面量比较会收窄为该字面量;== null(宽松相等)一次检查就能同时匹配 null 和 undefined,这也是唯一推荐使用宽松相等的地方。

比较两个变量时,两者都会被收窄为它们可能共有的类型:如果 a: string | number 和 b: string | boolean 满足 a === b,那么在 if 里两者都是 string。

in 运算符

"key" in obj 会把对象类型的联合收窄为拥有(或可能拥有)该属性的成员。

一旦确认 unknown 是对象,in 也可以用在它上面:在 typeof v === "object" && v !== null && "id" in v 之后,TypeScript 知道 v 有一个类型为 unknown 的 id 属性。对于自己设计的联合类型,使用一个共同的标签属性(kind: "fish")比探测方法更清晰:这种模式叫作可辨识联合。

赋值收窄

变量有一个声明类型,还有一个随赋值变化的收窄类型。给变量赋值会把它收窄为该值的类型,但不会超出声明类型。

收窄在哪些情况下会失效

收窄是局部且保守的。以下几种情况会让它重置:

  • 不同的表达式。 检查 obj.name 会收窄 obj.name(以及 obj["name"]),但当 key 是 string 变量而不是字面量时,不会收窄 obj[key],也不会收窄检查之前创建的副本。
  • 回调与重新赋值。 在回调内部,一个已收窄的 let 变量只有在回调创建之后不再被赋值时,才会保持收窄。从不重新赋值的 const 或参数会一直保持收窄。
  • 藏在辅助函数里的检查。 函数 isString(x: unknown): boolean 不会告诉编译器任何信息。把它的返回类型写成类型谓词 x is string,调用它就能像 typeof 一样收窄。

编译器报告 index.ts(5,38): error TS18048: 'x' is possibly 'undefined'. 回调可能在 x = undefined 之后才执行。删掉最后那次赋值(或者在 if 里把值复制到一个 const 中),代码就能通过编译,并输出两次 5。

返回 boolean 的辅助函数,可以通过声明它所证明的内容来修复。这就是类型守卫:

function isString(value: unknown): value is string {
  return typeof value === "string";
}

从 TypeScript 5.5 开始,编译器会为简单的箭头函数推断出这样的谓词,这就是为什么 list.filter((x) => x !== undefined) 现在返回的数组不含 undefined。

常见问题

TypeScript 中的类型收窄是什么?

收窄是指 TypeScript 根据代码执行的检查,在某个代码块中细化变量的类型。在 if (typeof x === "string") 之后,string | number 在 if 里就只是 string,在 else 里就只是 number。编译器会跟踪 if、else、return、switch、&&、|| 和 ?:,推算出每个位置的类型,这就叫控制流分析。

为什么 TypeScript 没有收窄我的类型?

常见原因有:检查的表达式和使用的表达式不是同一个(检查的是 obj.a,使用的却是 obj[key],而 key 的类型是 string);值是一个 let 变量,在创建回调之后又被重新赋值,于是回调丢失了收窄;或者检查藏在一个返回普通 boolean 的辅助函数里,而不是返回类型谓词 x is T。

类型收窄在运行时起作用吗?

检查本身会运行:typeof、instanceof、in 和 === 都是普通的 JavaScript。收窄只发生在编译期。TypeScript 读取你的运行时检查,并相应地调整静态类型,编译出的 JavaScript 中不会多出任何东西。

在 TypeScript 中如何收窄 unknown 类型?

用同样的检查:typeof value === "string"、Array.isArray(value)、value instanceof Date,对象则用 typeof value === "object" && value !== null && "id" in value。需要复用的检查,可以写成返回类型为 value is T 的类型守卫函数。

在 TypeScript 中如何从数组中过滤掉 undefined?

从 TypeScript 5.5 开始,items.filter((x) => x !== undefined) 返回不含 undefined 的 T[],因为编译器会把这个回调推断为类型谓词。在更早的版本中,需要自己写出谓词:items.filter((x): x is T => x !== undefined)。

Coddy 编程语言插图

和 Coddy 一起学编程

开始