跳到主要内容

第 4 章 · 类型收窄与守卫

读完本章你会
  • 掌握 typeof / in / instanceof / 等值判断四种收窄手段
  • 理解「控制流分析」:TS 怎么在分支里自动记住更具体的类型
  • 会写可辨识联合(discriminated union),并用 never 兜底做穷尽检查
  • 会写自定义类型守卫 arg is T 和断言函数 asserts cond
  • 分清「收窄」和「断言」的区别:前者是安全的,后者是绕过的

4.1 收窄:让联合类型「变小」

拿到一个联合类型,直接用会处处受阻——因为 TS 只允许「所有成员都支持」的操作。收窄就是在分支里把类型缩小到某个成员

4.1.1 typeof 收窄

function printId(id: string | number) {
if (typeof id === 'string') {
// ✅ id 收窄为 string
console.log(id.toUpperCase())
} else {
// ✅ id 收窄为 number(排除了 string)
console.log(id.toFixed(2))
}
}

typeof 能识别:string / number / boolean / symbol / bigint / undefined / object / function。注意 null 也会被 typeof 判成 object,所以「判 object」不等于「不是 null」。

4.1.2 等值收窄

直接比 null / undefined / 字面量,也能收窄:

function render(data: string | null | undefined) {
if (data === null) {
// data 是 null
return '无数据'
}
if (data === undefined) {
// data 是 undefined
return '未初始化'
}
// data 收窄为 string
return data.toUpperCase()
}

联合里的字面量成员,同样用等值收窄:

type Result = 'success' | 'error' | 'pending'

function handle(r: Result) {
if (r === 'success') {
// r 是 'success'
} else if (r === 'error') {
// r 是 'error'
} else {
// r 是 'pending'(穷尽!)
}
}

4.1.3 instanceof 收窄

针对类实例:

function describe(value: Date | string) {
if (value instanceof Date) {
return value.toISOString() // value: Date
}
return value.toUpperCase() // value: string
}

4.1.4 in 收窄(对象成员判断)

in 判断「有没有这个属性」,用于对象联合:

type Cat = { type: 'cat'; meow: () => void }
type Dog = { type: 'dog'; bark: () => void }

function speak(animal: Cat | Dog) {
if ('meow' in animal) {
animal.meow() // ✅ 有 meow 属性 → Cat
} else {
animal.bark() // Dog
}
}

4.1.5 控制流分析:TS 的「记忆」

上面所有这些,底层都是 TS 的控制流分析(control flow analysis)——它会沿着代码分支,跟踪每个变量在每个位置的「当前可能类型」。

function demo(x: string | number | null) {
if (typeof x === 'string') {
x.toUpperCase() // 这里 x: string
}
if (x !== null && typeof x === 'number') {
x.toFixed(2) // 这里 x: number
}
if (x === null) {
x // 这里 x: null
}
}
悬停看收窄

在编辑器里把鼠标悬停在分支内的变量上,能看到 TS「当前认为」的类型。这是学习控制流分析最直观的方式——看着类型如何一步步变窄,比背规则管用。


4.2 可辨识联合:类型设计的基石

4.2.1 用一个字面量字段区分成员

如果联合的每个成员都有一个可辨识字段(通常是 typekind,值是字面量),就能用它收窄:

type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rect'; width: number; height: number }

function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2 // shape: circle
case 'square':
return shape.size ** 2 // shape: square
case 'rect':
return shape.width * shape.height // shape: rect
}
}

switch (shape.kind) 之后,每个 case 里 TS 自动把 shape 收窄到对应成员——这就是可辨识联合

4.2.2 穷尽检查:给未来加保险

switch 的最后加一个 never 兜底,以后往联合里加新成员时,这里会立刻报错提醒你补分支:

function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2
case 'square': return shape.size ** 2
case 'rect': return shape.width * shape.height
default: {
// 所有成员都处理完时,shape 在这里是 never
const _exhaustive: never = shape // 若 Shape 加了新成员,这里报错
throw new Error(`未知的形状: ${shape}`)
}
}
}

可辨识联合是 TS 里最实用的类型模式之一,第 7、16 章做类型体操时它也是核心武器。


4.3 自定义类型守卫:arg is T

typeof / in 处理内置结构够用,但真实数据的校验经常是「检查多个字段」。这时把校验逻辑封装成类型守卫,TS 就能复用收窄结果。

语法:返回类型写 arg is T,表示「如果函数返回 true,那么 arg 就是 T」:

interface User { id: number; name: string; email: string }

// 校验任意数据是不是一个合法的 User
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return typeof v.id === 'number'
&& typeof v.name === 'string'
&& typeof v.email === 'string'
}

用起来,if (isUser(x)) 之后 x 就被收窄成 User

const raw: unknown = JSON.parse('...')

if (isUser(raw)) {
raw.email.toUpperCase() // ✅ 这里 raw 已经是 User
} else {
// raw 还是 unknown
}
为什么返回值要写 value is User

如果不写 is,函数返回 boolean,TS 只知道「true / false」,不知道「true 时 value 是什么」。value is User 就是告诉 TS:「返回 true 的那一刻,value 的类型是 User」——这就是把收窄逻辑「外包」出去的接口。

守卫的典型场景:

  • 校验解析后的 JSON(接口数据)
  • 表单 / 配置文件的结构校验
  • 把「窄类型」从「宽类型」里安全地挑出来

4.4 断言函数:asserts cond

类型守卫是「是/否」问答;断言函数是「不满足就抛错」:

function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error('不是合法的 User')
}
}

function handle(raw: unknown) {
assertIsUser(raw) // 不通过就直接抛错
raw.email.toUpperCase() // ✅ 断言之后,raw 收窄为 User
}

区别对比:

类型守卫 is断言函数 asserts
返回值返回 boolean不返回(不满足则抛错)
调用后进入 if 分支才收窄调用后立即收窄
失败行为走 else抛异常中断
// 守卫:要用 if 包起来
if (isUser(x)) { /* x 是 User */ } else { /* x 不是 */ }

// 断言:调用后直接生效
assertIsUser(x)
x.email // ✅ 类型已经是 User
什么时候用断言函数

前置条件检查(入口参数、配置文件、环境变量)尤其合适——「不满足就别继续跑了」。守卫则适合「可能是、也可能不是,你来分流」。


收窄 vs 断言:一张图记住

收窄(typeof / in / instanceof / 守卫 / 断言函数)
→ TS 根据代码结构「推理」出更具体的类型
→ 安全,不改变运行时

断言(as / !)
→ 你「强制指定」类型
→ 绕过检查,运行时炸了不负责

原则:能用收窄解决的问题,不要用断言。收窄是「让类型系统自己得出结论」,断言是「说服类型系统接受你的结论」。


本章小结

  • typeof / in / instanceof / 等值判断,把联合类型收窄到具体成员
  • 控制流分析是这一切的底层,悬停看类型是最好的学习法
  • 可辨识联合:用字面量字段区分成员,配合 switch + never 兜底做穷尽检查
  • 自定义守卫 arg is T 复用收窄逻辑;断言函数 asserts cond 做前置条件
  • 收窄安全、断言绕过——优先收窄

本章练习

前置挑战(学完第 7 章后回来做)

挑战题库 看 00043「实现 Exclude」——「从 T 中排除 U」。它依赖第 7 章的分布式条件类型,本章的联合心智是它的前置。先记下,到时回来。

练习 1 · 穷尽检查

定义 type Action = { type: 'add'; payload: number } | { type: 'reset' } | { type: 'undo' },写一个 reducer 函数用 switch 处理,最后加 never 兜底。然后给 Action 加一个新成员 { type: 'redo' },观察兜底处是否报错。

练习 2 · 自定义守卫

写一个 isEvent 守卫,校验一个 unknown 值是「有 type: stringpayload?: unknowntime: number」的事件对象。再用它收窄一段解析后的数据。

练习 3 · 断言函数

把练习 2 的守卫包装成 assertIsEvent(value: unknown): asserts value is Event,在函数入口调用,体会「调用后立即收窄」与守卫的差别。

练习 4 · in 收窄对象联合

定义 type Result = { ok: true; data: string } | { ok: false; error: string },用 in 或字段判断收窄,分别打印 dataerror,并比较「用 ok 字段判断」和「用 data/error 字段存在性判断」两种写法。

练习 5 · 守卫与断言混用

写一个 parseConfig(input: unknown): Config 函数:先用守卫判断是否为合法配置,不是就抛错;再用断言函数保证关键字段非空。目标:函数体内访问字段时全程零报错(全部靠收窄,不靠 as 硬来)。