跳过导航,直达内容
2026-04-02 技术团队 9 分钟

TypeScript 高级类型实战:让类型系统为你工作

TypeScript前端类型系统
TypeScript 高级类型实战:让类型系统为你工作

TypeScript 的类型系统足够强大,于是不少人写起了"类型体操":能在编译期算阶乘、做字符串替换,看起来很酷。但我们在项目里用到的模式其实很少——标准只有一个:它能不能拦住真实的 bug。这篇列出我们真正在用的几种,以及明确不建议用的地方。

条件类型:把"取里面的类型"变成可复用的工具

条件类型(T extends U ? X : Y)最实用的场景,是把第三方库或异步结构里的类型"剥"出来,避免到处手写重复的泛型:

// 取出数组元素类型
type ElementOf<T> = T extends (infer U)[] ? U : never

// 取出函数参数类型(做统一封装时很有用)
type ParamsOf<T> = T extends (...args: infer P) => unknown ? P : never

// 把 Promise 里的类型取出来——不过优先用内置的 Awaited
type ValueOf<T> = T extends Promise<infer U> ? U : T

判断标准很简单:这个类型工具是不是被用在两个以上地方?只为了"写得更聪明"而抽出来的类型,通常半年后没人敢动。

模板字面量类型:让字符串约定变成编译期规则

事件名、缓存 key、路由参数这类"有格式约定的字符串",用模板字面量类型可以让拼错在编译期就报出来。这是我们在前端埋点与接口封装里用得最多的一种:

// 事件名必须是「模块:动作」的组合,拼错立刻报错
type Module = 'user' | 'order' | 'product'
type Action = 'created' | 'updated' | 'deleted'
type AppEvent = `${Module}:${Action}`   // "user:created" | ... | "product:deleted"

// 形如 /api/users/123 的路由:数字段写成 number,就不会误传字符串
type UserRoute = `/api/users/${number}`

收益很直接:以前要等埋点数据对不上才发现的问题,现在在编辑器里就红了。

可辨识联合:状态机最省事的写法

页面状态(加载中 / 成功 / 失败 / 空)如果用几个布尔标志拼,最容易出现"加载中同时显示错误"这种自相矛盾的状态。把状态写成互斥的几支,编译器会帮你检查有没有漏分支:

type FetchState<T> =
  | { kind: 'loading' }
  | { kind: 'error'; message: string }
  | { kind: 'empty' }
  | { kind: 'ready'; data: T }

function render<T>(state: FetchState<T>) {
  switch (state.kind) {
    case 'loading': return '加载中…'
    case 'error':   return state.message
    case 'empty':   return '暂无数据'
    case 'ready':   return state.data        // 这里能直接访问 data,不需要断言
    default: {
      const never: never = state            // 将来新增状态时,这行会编译报错提醒你补分支
      return never
    }
  }
}

两个性价比最高的关键字

satisfies:既能校验"结构对不对",又保留字面量类型的精确推断。写配置对象最合适——加类型注解会丢掉字面量,不加又失去检查,它两个都要:

const routes = {
  home: '/',
  news: '/news',
} satisfies Record<string, `/${string}`>

// routes.home 的类型精确到 "/",而不是宽泛的 string

品牌类型(Branded Type):运行时都是 string,但编译期把它们区分开,防止把用户 ID 当订单 ID 传进去。我们只在少量"混用会出事故"的 ID 上用,不为此重写整个模型层:

type UserId = string & { readonly __brand: 'UserId' }
type OrderId = string & { readonly __brand: 'OrderId' }

declare function getOrder(id: OrderId): Promise<unknown>
// getOrder(userId)   // 传错类型时编译期就会报错

💡 实践建议

  • 优先用内置类型(Awaited / ReturnType / Parameters),自己写之前先查一遍有没有现成的
  • 一个类型工具超过五行、或需要注释解释"它在算什么",就换成直白的重复代码——可读性比少写几行重要
  • 类型体操会拖慢编译:复杂的递归类型能让大项目的类型检查慢好几倍,改完看一眼耗时
  • 类型是防线不是终点——接口返回、URL 参数、本地存储这些运行时边界仍要做校验

我们的取舍

真实项目里我们常用的是上面这几种:条件类型做类型提取、模板字面量约束字符串格式、可辨识联合管状态、satisfies 管配置。共同的判断标准是"写一次,能拦住未来的一批错误"。其余的类型技巧,学到能看懂别人的代码就够了。

如果你手上有一个类型混乱、报错频繁的 TypeScript 项目,我们在类型治理与渐进重构上做过不少这类活,欢迎联系我们聊聊现状。