类型收窄是指 TypeScript 根据代码已经做过的检查,在代码的某个位置为一个值推算出更具体的类型。一个 string | number 参数在 if (typeof x === "string") 里会变成 string,在 else 里会变成 number。
如果在检查之前调用 value.toFixed(2),就会产生编译错误,因为 string 上没有 toFixed。检查是普通的 JavaScript,在运行时执行;收窄则是编译器读取这个检查并调整类型。不会额外生成任何代码。
控制流分析
TypeScript 会跟踪函数中的每一条路径:if/else、提前的 return 和 throw、switch、循环,以及短路运算符 &&、||、?? 和 ?:。在每个位置,变量的类型就是那里仍然可能出现的类型。
提前返回的写法(“卫语句”)是最易读的收窄方式:先处理特殊情况,函数剩下的部分就可以使用干净的类型。
所有收窄方式
| 形式 | 示例 | 收窄范围 |
|---|---|---|
| typeof | typeof x === "string" | 原始类型和函数 |
| 真值判断 | if (x) | 去掉 null、undefined 和假值字面量 |
| 相等判断 | x === "a"、x == null、x !== undefined | 字面量、null、undefined |
in | "swim" in pet | 按属性区分对象联合类型 |
| instanceof | err instanceof TypeError | 类的实例 |
Array.isArray | Array.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)。