07-类型守卫与类型断言
类型守卫与类型断言
概述
类型守卫(Type Guard)和类型断言(Type Assertion)是 TypeScript 中处理"编译器不知道但开发者知道"类型信息的两类工具。Go 开发者对类型断言并不陌生(x.(Type)),但 TypeScript 提供了更丰富的类型收窄机制:typeof、instanceof、自定义守卫、可辨识联合(Discriminated Unions)等。
Go 开发者已知
// Go 类型断言
var x interface{} = "hello"
// 安全断言
s, ok := x.(string)
if ok {
fmt.Println(s) // "hello"
}
// 不安全断言(会 panic)
s2 := x.(string)
// 类型 switch
switch v := x.(type) {
case string:
fmt.Println("string:", v)
case int:
fmt.Println("int:", v)
default:
fmt.Println("unknown")
}Go 类型处理特点:
- 类型断言是运行时行为,基于
interface{}的动态类型 - 安全断言返回
(value, bool)模式 - 类型 switch 是穷举式的
- 没有编译时的类型守卫机制(TS 特有)
TypeScript 怎么做
typeof 守卫
function process(value: string | number): string {
// typeof 收窄类型
if (typeof value === 'string') {
// 这里 value 被收窄为 string
return value.toUpperCase()
}
// 这里 value 被收窄为 number
return value.toFixed(2)
}instanceof 守卫
class Dog {
bark() { console.log('Woof!') }
}
class Cat {
meow() { console.log('Meow!') }
}
function makeSound(animal: Dog | Cat): void {
if (animal instanceof Dog) {
animal.bark() // 收窄为 Dog
} else {
animal.meow() // 收窄为 Cat
}
}自定义守卫(is 关键字)
interface Fish {
swim(): void
}
interface Bird {
fly(): void
}
// 自定义类型守卫
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined
}pet is Fish 这种返回类型写法称为 类型谓词(Type Predicate),is 是关键字。它的语法是:
function 函数名(参数: 联合类型): 参数名 is 收窄类型 {
return boolean表达式
}工作原理:
- 函数必须返回
boolean,TS 编译器根据返回值决定类型的收窄 - 当返回
true时,编译器将参数的类型收窄(narrow)为is后的类型 - 当返回
false时,编译器排除该类型,保留联合中剩余的候选类型
function move(pet: Fish | Bird): void {
if (isFish(pet)) {
pet.swim() // 收窄为 Fish
} else {
pet.fly() // 收窄为 Bird
}
}
// 更实用的守卫
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function isNonEmptyArray<T>(value: unknown): value is T[] {
return Array.isArray(value) && value.length > 0
}类型守卫"可能"会被绕过
类型守卫的判定条件本质上是开发者自行编写的逻辑。编译器无条件信任守卫函数返回的 boolean 值,不会去验证条件是否真的能正确区分类型:
// 完全"合法"但毫无意义的类型守卫
function isNumber(x: string | number): x is number {
return Math.random() > 0.5 // 一半概率把 string 错判为 number!
}
// 故意绕过的守卫
function isString(x: unknown): x is string {
return true // 永远返回 true —— 编译器照单全收
}这揭示了一个容易被忽视的事实:类型守卫的核心价值在于便利编译器进行类型收窄,而非约束开发者的行为。 它更像是一个"类型承诺"(type promise)——开发者在守卫中承诺"当条件满足时,该值一定是某类型",编译器选择相信这个承诺,并据此收窄后续代码中的类型。
// 错误守卫演示
interface User { name: string }
interface Admin { name: string; role: string }
function isAdmin(user: User | Admin): user is Admin {
// 假设我们"忘了"检查 role 属性
return user.name.length > 0 // 这根本不是有意义的判断
}
function process(user: User | Admin): void {
if (isAdmin(user)) {
user.role.toUpperCase() // 编译通过,运行时可能崩溃!
}
}如何保障守卫的正确性?
- 守卫条件必须与类型定义严格对应 —— 检查最能区分该类型的唯一属性
- 配合单元测试 —— 分别用该类型和非该类型的值验证守卫行为
- 优先使用
typeof/instanceof/in等内置守卫 —— 这些由 JS 引擎实现,逻辑可靠 - 不要在生产守卫中写入复杂业务逻辑 —— 逻辑越简单,出错概率越低
理解这一点后,就能更理性地看待类型守卫:它是编译期的辅助工具,不是运行时的安全网。运行时安全需要额外的验证手段(如 Zod、io-ts 等 schema 验证库)。
可辨识联合(Discriminated Unions)
// 可辨识联合 —— 一个公共的 literal 属性作为判别器
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rectangle':
return shape.width * shape.height
case 'triangle':
return (shape.base * shape.height) / 2
default:
const _exhaustive: never = shape
return _exhaustive
}
}类型断言(as / <> / !)
// as 断言
const input = document.getElementById('input') as HTMLInputElement
input.value = 'hello'
// <> 断言(JSX 中不能使用)
const input2 = <HTMLInputElement>document.getElementById('input')
// 非空断言 !
function process(name?: string): void {
const s: string = name! // 断言 name 非 null/undefined
}
// 双重断言(极少使用)
const x = ('hello' as unknown) as number差异分析
| 维度 | Go | TypeScript |
|---|---|---|
| 运行时类型检查 | x.(Type) 基于 interface{} | typeof / instanceof 基于 JS 运行时 |
| 编译时类型收窄 | 不支持 | 类型守卫、可辨识联合 |
| 自定义守卫 | 不支持 | pet is Fish 语法 |
| 断言语法 | x.(Type) 或 x.(Type) 安全形式 | as Type 或 <Type> |
| 断言安全性 | 不安全的断言会 panic | 断言不改变运行时行为 |
| 非空断言 | 不支持 | ! 后缀操作符 |
| 可辨识联合 | 不支持(无字面量类型) | 一等公民 |
关键区别:编译时 vs 运行时
TypeScript 的类型检查是编译时的,类型在运行时被抹除。这意味着:
// TS 的类型守卫编译后消失
function isString(x: unknown): x is string {
return typeof x === 'string'
}
// 编译后:
// function isString(x) {
// return typeof x === 'string'
// }
// Go 的类型断言是运行时行为
// v, ok := x.(string) // 运行时检查Go 的类型断言依赖 interface{} 内部存储的动态类型信息,而 TypeScript 的类型守卫依赖 JavaScript 运行时提供的 typeof / instanceof 等操作。
Bad Practice
// Bad: 滥用 as 断言
const data: unknown = fetchData()
const user = data as User // 运行时完全可能不是 User
user.name.toUpperCase() // 运行时崩溃
// Bad: 用 as 逃避类型问题
function add(a: number, b: number): number {
return (a + b) as unknown as number // 毫无意义
}
// Bad: 过度使用非空断言
function process(user?: { name?: string }) {
const name = user!.name! // 如果 user 或 name 是 undefined,运行时崩溃
console.log(name.toUpperCase())
}
// Bad: 用 as 代替类型守卫
function handle(input: string | number) {
(input as string).toUpperCase() // 如果 input 是 number,运行时崩溃
}Best Practice
// Good: 优先类型守卫,次选类型断言
function process(input: string | number): string {
if (typeof input === 'string') {
return input.toUpperCase()
}
return input.toFixed(2)
}
// Good: 使用可辨识联合替代多重断言
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; message: string }
| { status: 'loading' }
function handleResult<T>(result: Result<T>): string {
switch (result.status) {
case 'success':
return `Data: ${result.data}`
case 'error':
return `Error: ${result.message}`
case 'loading':
return 'Loading...'
}
}
// Good: 自定义守卫提高代码可读性
interface User {
id: number
name: string
email: string
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
)
}
function handleResponse(data: unknown): void {
if (isUser(data)) {
console.log(data.name) // 类型安全
}
}
// Good: 必要时的类型断言(框架/库边界)
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
// Good: 使用 satisfies 操作符(TS 4.9+)
type Palette = Record<string, string>
const palette = {
red: '#FF0000',
green: '#00FF00',
blue: '#0000FF',
} satisfies Palette
// palette.red 的类型是 string(满足约束),而非 string | undefinedas 断言的使用原则
- 尽可能不用 —— 优先类型守卫
- 仅在边界使用 —— DOM API、JSON.parse、第三方库
- 配合验证 —— 断言前进行运行时检查
- 宁可用 unknown 过渡 ——
as unknown as T比直接as T更显眼
// 可接受的使用场景
const data = JSON.parse(jsonString) as User[] // 外部边界
// 更好的做法
function parseUsers(json: string): User[] {
const data = JSON.parse(json)
if (!Array.isArray(data) || !data.every(isUser)) {
throw new Error('Invalid user data')
}
return data
}code-tabs 对比
总结
| Go 概念 | TypeScript 对应 |
|---|---|
x.(Type) 安全断言 (v, ok) | 类型守卫 typeof/instanceof/is |
x.(Type) 不安全断言 | as Type 断言 |
switch v := x.(type) | 可辨识联合 + switch kind |
| 无对应 | 自定义守卫 pet is Fish |
| 无对应 | 非空断言 ! |
| 无对应 | satisfies 操作符 |
核心原则:优先类型守卫(编译时收窄),次选类型断言(告知编译器),类型断言越少,代码越安全。
下一章将介绍 高级类型:联合、交叉与映射类型。
附录:类型守卫 ≠ 类型保障
类型守卫帮助编译器收窄类型,但它并不等同于"运行时类型保障"。两者的区别非常关键:
| 维度 | 类型守卫(Type Guard) | 类型保障(Type Guarantee) |
|---|---|---|
| 检查时机 | 编译期——告知编译器 | 运行时——验证数据 |
| 错误后果 | 编译器被误导,生成错误的收窄 | 运行时崩溃或数据污染 |
| 对数据源的态度 | 信任开发者写的条件 | 不信任任何外来数据 |
| 典型工具 | pet is Fish、typeof、instanceof | Zod、io-ts、Ajv、class-validator |
类型守卫不能做什么
interface APIResponse {
code: number
data: unknown
}
// 守卫:检查 code 是否为 200
function isSuccess(res: APIResponse): res is APIResponse & { data: User } {
return res.code === 200
}
// 问题:即使 code 是 200,data 也可能不是 User 类型
const response = await fetch('/api/user').then(r => r.json())
if (isSuccess(response)) {
// TS 认为 response.data 是 User,但如果后端返回了意外格式……
console.log(response.data.name) // 编译通过,运行时可能 undefined!
}类型守卫能做的只是按条件改变代码分支中的类型信息,它不能验证实际数据的结构。上面的例子中,isSuccess 只检查了 code 字段,根本无法保证 data 结构符合 User 定义。
真正需要类型保障的场景
import { z } from 'zod'
// 用 Zod schema 定义运行时验证
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
type User = z.infer<typeof UserSchema>
// User 类型与 schema 保持同步
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/user/${id}`)
const raw: unknown = await res.json()
// 运行时验证 + 类型收窄 二合一
return UserSchema.parse(raw)
// Zod 在运行时验证数据结构,通过后 TS 类型自动收窄为 User
}Zod 这类验证库的 parse() 方法既是运行时验证(检查数据是否真的符合结构),也是编译时类型守卫(返回值的类型自动收窄)。它把两种能力合二为一,弥补了纯 TS 类型守卫无法触及的运行时安全真空。
关系总结
类型安全谱系:
纯类型注解 类型守卫 运行时验证库
───────── ──────── ────────
编译时约束 编译时收窄 运行时验证
零运行时开销 零运行时开销 有运行时开销
信任代码编写者 信任守卫逻辑 不信任任何数据
较弱 ──────────────────────────────────────────→ 较强- 类型守卫是编译层的"便利工具"——帮助编译器理解代码意图,从而更好地做类型收窄和自动补全
- 类型保障是运行层的"安全网"——从根本上验证数据是否合规,防范非法数据进入核心逻辑
- 组合使用才是最佳实践:内部逻辑用守卫收窄(零开销),外部边界用验证库兜底(保证安全)
了解 typeof / instanceof / in 等内置守卫能提供真正的运行时检查,而自定义 pet is Fish 守卫的安全程度完全取决于开发者写的条件是否正确。意识到这个差距,就能在合适的场景选择合适的工具。