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

真值判断适合排除 nullundefined 等假值,但可能误伤 0false 和空字符串:

function printLength(text: string | null) {
  if (text !== null) {
    console.log(text.length) // 空字符串仍是合法输入
  }
}

需要判断“是否缺失”时,优先显式检查 null / undefined,不要默认把所有假值当作无效。

四、ininstanceof

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: booleandata?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 几乎总是在绕过不兼容事实,应视为需要重新设计边界的信号。

九、谨慎使用非空断言

后缀 ! 告诉编译器值不是 nullundefined

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 由真实解析器产生,类型关系有运行时行为支撑。

十二、常见误区

  1. unknown 立即断言成具体类型:这只是换了 any 的写法,没有完成验证。
  2. 用真值判断过滤所有输入:合法的 0false 和空字符串会被一起排除。
  3. 让类型守卫承诺过多:谓词必须由完整、经过测试的检查支撑。
  4. 把断言当类型转换as number 不会把字符串变成数字。
  5. 到处写非空断言:它常常是在隐藏生命周期或状态建模问题。
  6. 只在 switch 里写 default:没有 never 检查时,新联合成员可能被静默吞掉。

小结

不可信数据从 unknown 开始,通过 typeofininstanceof、判别字段或类型守卫逐步收窄。用可辨识联合排除非法状态,用 never 固定穷尽处理。断言和非空断言不会产生运行时保护,应该只表达确有依据的额外事实;配置对象则优先使用 satisfies,既验证形状又保留精确推断。

官方资料

系列导航:目录 · 上一篇 · 下一篇