理解 TypeScript 中的类型收窄
你编写了一个类型守卫,但 TypeScript 仍然提示属性不存在。或者你过滤了一个数组,但结果类型仍然是联合类型。这些困扰源于你对类型收窄的理解与 TypeScript 控制流分析实际运作方式之间的差距。
本文将帮助你建立对 TypeScript 类型收窄的清晰认知模型——编译器如何在代码中跟踪类型,以及何时会丢失这些信息。
核心要点
- 类型收窄是 TypeScript 的控制流分析,它基于可验证的运行时检查在执行路径中跟踪类型
- 核心收窄机制包括
typeof、instanceof、真值检查、相等性检查、in操作符和可辨识联合类型 - 用户自定义类型守卫通过类型谓词实现自定义收窄,但 TypeScript 不会验证你的谓词逻辑
never类型支持编译时穷尽性检查,以捕获未处理的情况- 类型收窄在回调函数边界、属性重新赋值和复杂别名模式中会失效
控制流分析的工作原理
TypeScript 的类型收窄并非魔法。它是编译器跟踪代码执行路径,并根据可验证的运行时检查更新类型信息的过程。
当你编写条件语句时,TypeScript 会分析每个分支中必然为真的条件:
function process(value: string | number) {
if (typeof value === 'string') {
// TypeScript 知道:这里 value 是 string 类型
return value.toUpperCase()
}
// TypeScript 知道:这里 value 是 number 类型
return value.toFixed(2)
}
编译器识别 typeof 检查,将其视为收窄构造,并相应地细化类型。这对几个 JavaScript 操作符会自动发生。
核心收窄机制
typeof 和 instanceof
typeof 操作符可以可靠地收窄基本类型。TypeScript 理解它的特性——包括 typeof null 返回 "object"。
function handle(x: unknown) {
if (typeof x === 'string') return x.length
if (typeof x === 'number') return x.toFixed(2)
}
对于类实例,instanceof 提供类似的收窄功能:
if (error instanceof TypeError) {
console.log(error.message)
}
真值检查和相等性检查
真值检查可以排除 null 和 undefined:
function greet(name: string | null) {
if (name) {
return `Hello, ${name}` // name 是 string 类型
}
}
相等性检查可以收窄到字面量类型:
type Status = 'pending' | 'complete' | 'failed'
function handle(status: Status) {
if (status === 'complete') {
// status 是 'complete' 字面量类型
}
}
in 操作符和可辨识联合类型
in 操作符检查属性是否存在,支持鸭子类型模式:
if ('radius' in shape) {
return Math.PI * shape.radius ** 2
}
可辨识联合类型将此与字面量类型结合,实现强大的收窄模式:
type Result<T> =
| { success: true; data: T }
| { success: false; error: string }
function handle<T>(result: Result<T>) {
if (result.success) {
return result.data // TypeScript 知道 data 存在
}
return result.error // TypeScript 知道 error 存在
}
Discover how at OpenReplay.com.
用户自定义类型守卫
当内置检查不够用时,类型谓词允许你定义自定义守卫:
function isString(value: unknown): value is string {
return typeof value === 'string'
}
需要注意:TypeScript 不会验证你的谓词逻辑。你在断言这种关系——如果你的检查是错误的,类型也会是错误的。
现代 TypeScript(5.5+)在这方面有所改进。Array.filter 现在可以自动为简单的回调函数推断类型谓词,消除了一个常见的痛点:
const mixed: (string | number)[] = ['a', 1, 'b', 2]
const strings = mixed.filter(x => typeof x === 'string')
// TypeScript 在许多情况下会推断为 string[]
使用 never 进行穷尽性检查
never 类型支持编译时穷尽性检查:
type Circle = { kind: 'circle'; radius: number }
type Square = { kind: 'square'; side: number }
type Triangle = { kind: 'triangle'; base: number; height: number }
type Shape = Circle | Square | Triangle
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2
case 'square': return shape.side ** 2
case 'triangle': return 0.5 * shape.base * shape.height
default:
const _exhaustive: never = shape
return _exhaustive
}
}
如果你添加了新的形状变体,TypeScript 会在 never 赋值处报错——强制你处理它。
类型收窄不是什么
两个经常与类型收窄混淆的概念需要澄清:
类型断言(as) 完全绕过类型系统。它们不是收窄——而是覆盖。
satisfies 操作符 验证表达式是否匹配某个类型,但不改变它。对捕获错误很有用,但不是收窄机制。
类型收窄何时失效
TypeScript 的控制流分析有其局限性。类型收窄在以下情况下不会保持:
- 回调函数边界(尽管在 TypeScript 5.4+ 中,使用
const变量的闭包现在可以保持收窄) - 检查之间的属性重新赋值
- 复杂的别名模式
当类型收窄意外失效时,检查编译器是否真的能从你的检查追踪到使用位置的控制流。
总结
将类型收窄理解为 TypeScript 观察你的运行时检查并相应更新其知识的过程。编译器是保守的——只有在能够证明细化是合理的情况下才会收窄。
编写 TypeScript 能够跟踪的检查。优先使用可辨识联合类型而非类型断言。使用穷尽性检查在编译时而非运行时捕获遗漏的情况。
目标不是与类型系统对抗,而是构建代码使类型收窄自然发生。
常见问题
TypeScript 的控制流分析不会在回调函数边界保持类型收窄,因为回调可能稍后执行,那时变量的类型可能已经改变。解决方法是在回调之前将收窄后的值赋给一个 const 变量,或者在回调内部使用类型守卫。
类型守卫是 TypeScript 识别并用于安全收窄类型的运行时检查。使用 'as' 的类型断言告诉 TypeScript 将值视为特定类型,而不进行任何运行时验证。类型守卫更安全,因为它们涉及实际检查,而断言如果你的假设错误可能会隐藏 bug。
当你有共享公共字面量属性(如 'kind' 或 'type')的相关类型时,使用可辨识联合类型。它们在 switch 语句中提供自动收窄并支持穷尽性检查。类型守卫更适合验证外部数据或无法修改正在使用的类型时。
这通常意味着 TypeScript 失去了对你的类型收窄的跟踪。常见原因包括:检查属性后通过不同引用访问它、在检查和使用之间重新赋值变量,或检查发生在不同作用域。将类型检查移到更接近使用值的位置,或将收窄后的值存储在新的 const 变量中。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.