Back

理解 TypeScript 中的类型收窄

理解 TypeScript 中的类型收窄

你编写了一个类型守卫,但 TypeScript 仍然提示属性不存在。或者你过滤了一个数组,但结果类型仍然是联合类型。这些困扰源于你对类型收窄的理解与 TypeScript 控制流分析实际运作方式之间的差距。

本文将帮助你建立对 TypeScript 类型收窄的清晰认知模型——编译器如何在代码中跟踪类型,以及何时会丢失这些信息。

核心要点

  • 类型收窄是 TypeScript 的控制流分析,它基于可验证的运行时检查在执行路径中跟踪类型
  • 核心收窄机制包括 typeofinstanceof、真值检查、相等性检查、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)
}

真值检查和相等性检查

真值检查可以排除 nullundefined:

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 存在
}

用户自定义类型守卫

当内置检查不够用时,类型谓词允许你定义自定义守卫:

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.

OpenReplay