Skip to content

TypeScript 类型系统进阶:从类型体操到工程约束 ​

TypeScript 用得好不好,分水岭通常不在"会不会写泛型",而在于是否理解类型是编译期的约束工具,不是目的。

团队里常见的两个极端:一是全程 any,TypeScript 退化成带类型注解的 JavaScript;二是沉迷类型体操,写出几百行没人能维护的推导类型。真正有价值的类型设计,是在关键边界上花最少的类型成本,换取最大的错误拦截能力。

从结构类型说起 ​

TypeScript 是结构化类型系统:类型兼容看形状,不看名字。

ts
interface Point {
  x: number
  y: number
}

function print(point: Point) {
  console.log(point.x, point.y)
}

const coordinate = { x: 1, y: 2, z: 3 }
print(coordinate) // 合法,多余属性不参与检查

这带来灵活性,也带来一个常见误区:以为 interface 能保证运行时数据结构。实际上它只约束编译期,接口返回的数据仍需运行时校验。

条件类型与 infer ​

条件类型让类型具备分支能力,infer 则可以在匹配过程中提取子类型。

ts
type UnwrapPromise<T> = T extends Promise<infer Value>
  ? UnwrapPromise<Value>
  : T

type ElementOf<T> = T extends readonly (infer Item)[] ? Item : never

type A = UnwrapPromise<Promise<Promise<string>>> // string
type B = ElementOf<Array<{ id: number }>>        // { id: number }

这两个工具类型在实际项目中非常常用:封装异步工具函数时统一解包返回值,或者从数组类型中提取元素类型,避免手写重复定义。

条件类型默认会对联合类型逐个分发:

ts
type ToArray<T> = T extends unknown ? T[] : never
type C = ToArray<string | number> // string[] | number[]

利用分发可以精确处理联合类型,但如果不希望分发,需要用元组包裹:

ts
type ToArrayNoDistribute<T> = [T] extends [unknown] ? T[] : never
type D = ToArrayNoDistribute<string | number> // (string | number)[]

映射类型与递归 ​

映射类型用于批量改造对象属性,是很多工具类型的底层实现。

ts
type DeepPartial<T> = T extends (infer Item)[]
  ? DeepPartial<Item>[]
  : T extends object
    ? { [K in keyof T]?: DeepPartial<T[K]> }
    : T

这里显式排除了数组和函数,否则数组会被当成普通对象递归处理,得到奇怪的结果。写递归类型时一定要注意终止条件,否则容易触发 "Type instantiation is excessively deep"。

另一个高频场景是精确约束一组字符串键:

ts
type AppRoutes = {
  home: '/'
  archive: '/archive'
  post: `/posts/${string}`
}

const routes = {
  home: '/',
  archive: '/archive',
  post: '/posts/vue3'
} as const satisfies AppRoutes

as const 保留字面量类型,satisfies 在保留推导结果的同时做结构检查。相比直接标注 : AppRoutes,它能避免字面量被拓宽为 string。

用模板字面量约束事件名 ​

事件系统是模板字面量类型的典型用武之地:

ts
type EventMap = {
  change: { value: string }
  submit: { values: Record<string, string> }
}

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void
}

type FormHandlers = EventHandlers<EventMap>
// {
//   onChange: (payload: { value: string }) => void
//   onSubmit: (payload: { values: Record<string, string> }) => void
// }

keyof E 是 string | number | symbol 的联合,映射到模板字面量前要约束成 string,否则编译器无法确定插值结果。

这种写法适合 SDK、埋点系统、表单引擎这类"事件名有规律"的场景。普通业务组件没必要为了炫技引入复杂推导。

用可辨识联合表达状态 ​

比起用多个布尔值描述请求状态,可辨识联合能让非法状态无法被表达:

ts
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function assertNever(value: never): never {
  throw new Error(`Unexpected state: ${JSON.stringify(value)}`)
}

function render<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'idle':
      return '等待开始'
    case 'loading':
      return '加载中'
    case 'success':
      return state.data
    case 'error':
      return state.error.message
    default:
      return assertNever(state)
  }
}

新增状态时,assertNever 会立即报错,提示所有未处理的 switch 分支。这比 loading、error、data 三个独立字段可靠得多,因为后者可以同时为真。

类型与运行时校验的分工 ​

类型系统无法验证网络请求、用户输入、localStorage 读取等外部数据。关键边界应该引入运行时校验:

ts
import { z } from 'zod'

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  roles: z.array(z.enum(['admin', 'editor', 'viewer']))
})

type User = z.infer<typeof UserSchema>

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return UserSchema.parse(await res.json())
}

z.infer 从 schema 推导类型,保证类型与校验规则来自同一处定义。原则是:只在系统边界校验,内部数据信任类型系统,不要给每个函数都套一层 parse。

值得开启的编译选项 ​

json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "moduleResolution": "bundler",
    "target": "ES2022"
  }
}
  • noUncheckedIndexedAccess:数组下标和索引签名返回值自动包含 undefined,迫使处理越界情况。
  • exactOptionalPropertyTypes:区分"属性不存在"和"属性值为 undefined",对序列化逻辑很有价值。
  • verbatimModuleSyntax:让类型导入在编译后完全消失,避免打包器误判副作用。

开启这些选项会暴露大量存量问题,建议新项目直接开启,老项目分批处理。

常见坑 ​

  1. 用类型断言代替校验。as User 不会检查数据,只会在编译期闭嘴。
  2. 类型推导过度。复杂工具类型应补测试(如 expectTypeOf)和注释,否则后人不敢改。
  3. interface 与 type 混用无规范。团队统一约定:对象结构用 interface,联合、映射、工具类型用 type。
  4. 声明文件与实现漂移。手写 .d.ts 容易和实现脱节,优先从源码或 OpenAPI 生成类型。
  5. 忽略 any 的传染性。一个 any 会污染整条调用链,开启 noImplicitAny 并在 ESLint 中限制显式 any。

2019 年发布的 TypeScript 3.7 引入了可选链与空值合并,4.x 又补齐了模板字面量、satisfies 等能力。版本差异较大时,先确认项目的 TS 版本,再决定能用哪些语法。

小结 ​

类型系统的价值在于把隐式约定变成显式约束:接口形状、状态机、事件契约、配置结构。少写一些炫技类型,多把关键边界收口,TypeScript 才会真正降低维护成本。

基于 VitePress 构建 · 持续更新中