TypeScript 类型守卫的演进:从 typeof 到自定义守卫与断言函数

HTMLPAGE 团队
16 分钟阅读

类型守卫是 TypeScript 控制流分析的核心机制。本文从内置守卫(typeof、instanceof、in)出发,过渡到自定义类型谓词,再到断言函数和断言签名,最后讨论窄化在不同控制流结构中的边界行为。

#TypeScript #类型守卫 #类型收窄 #断言函数 #控制流分析

类型守卫(type guard)是 TypeScript 控制流分析的核心机制。它让编译器在特定代码路径里把一个宽类型窄化成更具体的类型。

很多人会用 typeofinstanceof,但到了需要自定义守卫的时候就卡住了——不知道怎么写类型谓词,不理解 asserts 签名的用途,不清楚 in 操作符的窄化规则。

内置守卫

TypeScript 编译器内置了对几个 JavaScript 操作符的类型窄化支持。

typeof

typeof 是最基础的窄化方式,但它的局限性很明显——只能区分 primitive 类型:

function format(value: string | number | Date): string {
  if (typeof value === 'string') return value.trim()
  if (typeof value === 'number') return value.toFixed(2)
  // 这里 value 是 Date
  return value.toISOString()
}

typeof 能识别的类型只有:stringnumberbooleansymbolbigintundefinedfunctionobject。注意 null 也会被 typeof 报为 'object',这是一个历史遗留 bug,但 TypeScript 在窄化时做了修正——typeof x === 'object' 会排除 null

instanceof

instanceof 用原型链检查来窄化对象类型:

class ApiError {
  constructor(public code: number, public message: string) {}
}

class NetworkError {
  constructor(public message: string, public retryable: boolean) {}
}

function handleError(err: ApiError | NetworkError) {
  if (err instanceof ApiError) {
    // err: ApiError
    console.log(`API Error ${err.code}: ${err.message}`)
  } else {
    // err: NetworkError
  }
}

instanceof 的窄化基于原型链——不是基于结构兼容性。所以两个没有继承关系的类,即使结构相同也不会相互匹配。

in

in 操作符检查属性是否存在,也能触发窄化:

type Dog = { bark: () => void }
type Cat = { meow: () => void }

function makeSound(animal: Dog | Cat) {
  if ('bark' in animal) {
    // animal: Dog —— 因为只有 Dog 有 bark 方法
    animal.bark()
  } else {
    // animal: Cat
    animal.meow()
  }
}

in 的窄化规则略有不同:如果被检查的属性只在联合的一个成员中出现,那就窄化到那个成员。如果属性在多个成员中都存在(即使类型不同),in 不会产生窄化。

自定义类型谓词

当内置守卫不够用时,可以写自定义类型守卫。核心是函数的返回类型写成 x is Type 的形式——这就是类型谓词(type predicate):

interface User { kind: 'user'; name: string; email: string }
interface Admin { kind: 'admin'; name: string; role: string; permissions: string[] }

function isAdmin(person: User | Admin): person is Admin {
  return (person as Admin).permissions !== undefined
}

function handlePerson(person: User | Admin) {
  if (isAdmin(person)) {
    // person: Admin
    console.log(person.role)
  }
}

类型谓词 person is Admin 告诉编译器:如果这个函数返回 true,那么入参的类型就收窄到 Admin。这相当于一个编译器的信任声明——它不检查函数体是否正确实现了这个断言。

类型谓词的常见陷阱

// 错误的守卫——检查的是 user,却说 is Admin
function isAdminWrong(person: User | Admin): person is Admin {
  return (person as User).email !== undefined  // 实际检查的是 email
}

编译器不会报错。类型谓词是开发者对编译器的一个承诺——你必须保证实现是正确的。写类型谓词时,测试覆盖率比类型安全更重要。

断言函数与断言签名

断言函数(assertion function)是类型守卫的另一种形式。它不返回 boolean,而是在条件不满足时抛出错误:

function assertString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string, got ${typeof value}`)
  }
}

function process(input: unknown) {
  assertString(input)
  // input: string —— 如果执行到了这里,断言必然满足
  console.log(input.toUpperCase())
}

asserts value is string 的含义是:这个函数要么抛异常,要么把 value 的类型收窄到 string

断言函数的实际价值是在数据验证场景:

interface Config {
  host: string
  port: number
  tls: boolean
}

function assertConfig(obj: unknown): asserts obj is Config {
  if (typeof obj !== 'object' || obj === null) throw new Error('not an object')
  if (!('host' in obj) || typeof (obj as any).host !== 'string') throw new Error('invalid host')
  if (!('port' in obj) || typeof (obj as any).port !== 'number') throw new Error('invalid port')
  if (!('tls' in obj) || typeof (obj as any).tls !== 'boolean') throw new Error('invalid tls')
}

function loadConfig(data: unknown) {
  assertConfig(data)
  // data: Config
  return { url: `https://${data.host}:${data.port}`, tls: data.tls }
}

断言函数和类型谓词的区别:

  • 类型谓词返回 boolean,用于 if/else 分支
  • 断言函数抛出或不抛出,用于校验前置条件后继续执行

窄化边界:哪些结构会触发窄化

不是所有检查都会触发窄化。TypeScript 的控制流分析在以下几种结构里生效:

// 1. if/else 分支
if (typeof x === 'string') { /* 窄化到 string */ }

// 2. switch/case
switch (x.type) {
  case 'a': /* 窄化到 TypeA */
}

// 3. 逻辑运算符
const val = typeof x === 'string' ? x : x.toString()
// 两个分支都窄化

// 4. 提前 return
function example(x: string | null) {
  if (x === null) return
  // x: string
}

// 5. 赋值(解构不总是触发窄化)
let y: string | number = 'hello'
y = 42
// y: number —— 赋值会更新变量的类型

但以下情况不会触发窄化:

// 解构到变量后,原始窄化不传递
interface Wrapper { value: string | number }
function bad({ value }: Wrapper) {
  if (typeof value === 'string') {
    // value: string —— 可以
  }
}

// 但是如果 value 是非原始类型,解构后的属性窄化在早期版本可能失效
// 在较新的 TypeScript 中,解构的属性窄化是支持的

自定义守卫 vs 类型断言

守卫和断言有一个根本区别:守卫是安全的类型窄化,断言是覆盖编译器的类型声明。

// 断言(不推荐)
function process(obj: unknown) {
  const data = obj as Config
  // 编译器不检查 obj 是否真的是 Config
}

// 守卫(推荐)
function processSafe(obj: unknown) {
  if (isConfig(obj)) {
    // obj: Config —— 编译器在 if 块内确认
  }
}

断言只是告诉编译器"我知道它是什么类型"。自定义守卫则让编译器参与到类型验证的流程里。

总结

守卫类型适用场景安全性
typeofPrimitive 类型区分编译器保证
instanceof类实例检查编译器保证
in属性存在检查编译器保证(有边界)
类型谓词 x is T自定义条件窄化开发者保证
断言签名 asserts x is T前置条件验证开发者保证

类型守卫的演进路径是:从编译器内置的硬编码规则(typeofinstanceof),到开发者可定制的类型谓词,再到可以中断执行流的断言签名。越往后的守卫越灵活,但越需要开发者保证正确性——编译器信任了你的实现。