TypeScript 类型收窄:unknown、never、断言与穷尽检查
TypeScript 类型收窄:unknown、never、断言与穷尽检查
系列导航:TypeScript 7 现代开发指南
上一篇:日常类型
下一篇:对象建模
类型收窄是 TypeScript 日常开发的核心:一个值进入函数时可能有多种形态,经过运行时判断后,编译器在当前分支中把它缩小为更具体的类型。边界越不可信,越应该从 unknown 开始验证,而不是用 any 或断言跳过检查。
本文覆盖内置收窄、自定义类型守卫、可辨识联合、never 穷尽检查、断言、非空断言和 satisfies。
一、any 会关闭检查
any 可以接收任何值,也可以执行任何操作:
function unsafeUpper(value: any) {
return value.toUpperCase()
}
unsafeUpper(42) // 编译通过,运行时失败
它还会向外传播:any 可以赋给几乎任何类型,让一次不安全边界污染后续代码。只在迁移遗留代码或类型系统确实无法表达的狭窄位置临时使用,并尽快把它封装起来。
二、unknown 迫使使用前验证
unknown 同样能接收任意值,但读取属性、调用或赋给更具体类型前必须收窄:
function upper(value: unknown): string {
if (typeof value === 'string') {
return value.toUpperCase()
}
return String(value)
}
网络响应、JSON.parse() 结果、消息队列载荷和用户输入都适合作为 unknown 进入系统。类型安全边界应完成运行时验证,再把确定的值交给内部业务代码。
三、typeof、相等性与真值收窄
typeof 适合原始类型:
function format(value: string | number): string {
if (typeof value === 'number') {
return value.toFixed(2)
}
return value.trim()
}
相等性判断也会关联两个值的类型:
function compare(left: string | number, right: string | boolean) {
if (left === right) {
left.toUpperCase() // 两者相等时只能共同为 string
}
}
真值判断适合排除 null、undefined 等假值,但可能误伤 0、false 和空字符串:
function printLength(text: string | null) {
if (text !== null) {
console.log(text.length) // 空字符串仍是合法输入
}
}
需要判断“是否缺失”时,优先显式检查 null / undefined,不要默认把所有假值当作无效。
四、in 与 instanceof
in 检查对象是否具有某个属性:
type Cat = { meow(): void }
type Dog = { bark(): void }
function speak(animal: Cat | Dog) {
if ('meow' in animal) {
animal.meow()
} else {
animal.bark()
}
}
instanceof 依赖真实的运行时构造器:
function formatDate(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value
}
接口在编译后不存在,不能写 value instanceof SomeInterface。来自另一个 iframe、重复安装的包或反序列化数据也可能不适合依赖 instanceof,此时判别字段或结构验证更可靠。
五、自定义类型守卫
重复验证逻辑可以封装为返回 value is Type 的函数:
interface User {
id: string
name: string
}
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
return typeof record.id === 'string' && typeof record.name === 'string'
}
const payload: unknown = JSON.parse('{"id":"u_001","name":"Ada"}')
if (isUser(payload)) {
console.log(payload.name)
}
类型谓词是一项承诺:若函数返回 true,值必须真的满足类型。验证条件与声明不一致时,编译器会被错误信息误导。复杂数据优先使用经过测试的 schema 库,并从 schema 推导类型。
断言函数用于验证失败时直接抛错:
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new TypeError('Invalid user payload')
}
}
const data: unknown = JSON.parse('{"id":"u_002","name":"Grace"}')
assertUser(data)
console.log(data.name)
六、可辨识联合让状态合法
给每个成员一个共享的字面量字段,TypeScript 就能精确收窄:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string }
function render<T>(state: RequestState<T>): string {
switch (state.status) {
case 'idle':
return '尚未加载'
case 'loading':
return '加载中'
case 'success':
return JSON.stringify(state.data)
case 'error':
return state.message
}
}
这比一个同时包含 loading: boolean、data?、error? 的宽接口更安全,因为非法状态根本无法构造。
七、never 与穷尽检查
never 表示不可能存在的值。总是抛错或永不正常返回的函数可返回 never:
function fail(message: string): never {
throw new Error(message)
}
它更常用于保证联合分支被完整处理:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
function assertNever(value: never): never {
throw new Error(`Unexpected shape: ${JSON.stringify(value)}`)
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.size ** 2
default:
return assertNever(shape)
}
}
以后给 Shape 增加新成员,assertNever(shape) 会产生编译错误,提醒开发者补齐分支。直接声明一个 never 变量通常没有业务意义。
八、类型断言不会转换数据
as Type 只影响静态检查,不生成运行时代码:
const element = document.querySelector('#app') as HTMLElement | null
它适合表达开发者确实掌握、但编译器无法推导的事实。它不适合把未验证响应强行声明成业务类型:
interface Article {
id: string
title: string
}
const raw: unknown = JSON.parse('null')
// const article = raw as Article
// 编译器会相信,但运行时仍然是 null
尽量先通过控制流和运行时验证收窄。双重断言 value as unknown as Target 几乎总是在绕过不兼容事实,应视为需要重新设计边界的信号。
九、谨慎使用非空断言
后缀 ! 告诉编译器值不是 null 或 undefined:
const app = document.querySelector('#app')!
它同样不会生成检查。元素不存在时,错误只是推迟到下一次访问。更稳妥的做法是显式验证:
const app = document.querySelector('#app')
if (!app) {
throw new Error('Missing #app element')
}
app.textContent = 'Ready'
测试夹具、框架生命周期或静态模板能严格保证存在时可以少量使用 !;普通业务数据不要依赖它掩盖初始化问题。
十、satisfies 检查而不粗暴改型
类型注解会让变量按目标类型使用;断言会要求编译器相信开发者;satisfies 则检查表达式满足目标约束,同时尽量保留表达式自己的精确信息:
type RouteName = 'home' | 'settings'
type RouteTable = Record<RouteName, `/${string}`>
const routes = {
home: '/',
settings: '/settings',
} as const satisfies RouteTable
const settingsPath = routes.settings // '/settings'
如果漏掉键、拼错键或路径不以 / 开头,声明处就会报错。satisfies 仍然只是静态检查,不能验证网络传来的对象。
十一、设计安全边界
推荐的数据流是:
外部数据(unknown)
→ 运行时解析 / schema 验证
→ 已收窄的领域类型
→ 内部业务函数
例如请求函数不要用一个没有验证支持的 <T> 让调用者“指定答案”:
interface Parser<T> {
parse(value: unknown): T
}
async function request<T>(url: string, parser: Parser<T>): Promise<T> {
const response = await fetch(url)
const payload: unknown = await response.json()
return parser.parse(payload)
}
这里的 T 由真实解析器产生,类型关系有运行时行为支撑。
十二、常见误区
- 把
unknown立即断言成具体类型:这只是换了any的写法,没有完成验证。 - 用真值判断过滤所有输入:合法的
0、false和空字符串会被一起排除。 - 让类型守卫承诺过多:谓词必须由完整、经过测试的检查支撑。
- 把断言当类型转换:
as number不会把字符串变成数字。 - 到处写非空断言:它常常是在隐藏生命周期或状态建模问题。
- 只在
switch里写default:没有never检查时,新联合成员可能被静默吞掉。
小结
不可信数据从 unknown 开始,通过 typeof、in、instanceof、判别字段或类型守卫逐步收窄。用可辨识联合排除非法状态,用 never 固定穷尽处理。断言和非空断言不会产生运行时保护,应该只表达确有依据的额外事实;配置对象则优先使用 satisfies,既验证形状又保留精确推断。