跳到主内容

TypeScript 类型体操进阶:条件类型与模板字面量的 6 个实战应用 ​

提起类型体操,很多人的印象停留在「用类型系统写 FizzBuzz」的炫技帖上,觉得好看不实用。但在真实的工程里,条件类型、infer 与模板字面量类型这「三件套」恰恰是把一堆 as any 和重复声明收敛掉的利器:表单字段的取值路径、事件总线的监听器签名、路由参数的解析,这些「字符串与类型联动」的场景,用好了能让重构时编译器替你找漏网。

这篇文章不讲理论推演,直接给 6 个从真实项目中抽象出来的应用,每个都能复制到你的项目里跑通。文末汇总翻车率最高的几个坑——类型体操的坑不在「写不出来」,而在「写出来了但不知道为什么这样」。

实战一:DeepPartial 与对象路径类型——工具类型的地基 ​

深分页接口返回嵌套对象,测试时要部分 mock;配置合并时希望每个字段都可选。浅层 Partial 不够用,递归条件类型补上:

ts
// 递归把所有嵌套属性变为可选;数组保持数组、Map 保持 Map,只处理普通对象
type DeepPartial<T> = T extends (infer U)[]
  ? DeepPartial<U>[]
  : T extends object
    ? { [K in keyof T]?: DeepPartial<T[K]> }
    : T

// 从对象类型中提取「a.b.c 形式」的合法路径,作为字符串字面量联合
type Path<T, Prefix extends string = ''> =
  T extends object
    ? { [K in keyof T & string]: Path<T[K], Prefix extends '' ? K : `${Prefix}.${K}`> }[keyof T & string]
    : Prefix

interface Order { buyer: { name: string; tags: string[] }; amount: number }

// 提示范围被精确约束,写错路径编译直接报错
type OrderField = Path<Order>  // "buyer" | "buyer.name" | "buyer.tags" | "amount"

Path 的用法非常广:表单库的 setFieldValue('buyer.name', v)、埋点系统的字段白名单、深拷贝函数的路径断言,全部可以用它把「字符串参数」变成「编译期校验」。第一个类型参数分发的写法也要注意:T extends (infer U)[] 先拦数组,避免数组被当普通对象按索引展开。

实战二与三:事件名解析与路由参数提取——模板字面量的主场 ​

模板字面量类型最擅长的,是让「字符串的拼接规则」进入类型系统。事件总线要求监听器签名与 emit 的事件名严格对应,靠手写两份声明必然漂移:

ts
// 事件名形如 "order:created",监听器参数由事件名后缀决定载荷类型
type EventPayload = {
  order: { id: string; amount: number }
  user: { uid: string }
}
type EventName = keyof EventPayload & string

// 用模板字面量拼接事件名,infer 从事件名反解出载荷
type ListenMap = {
  [K in EventName as `on:${K}`]: (payload: EventPayload[K]) => void
}
// 自动得到:{ 'on:order': (p: {id, amount}) => void; 'on:user': (p: {uid}) => void }

// 路由参数提取:从 "/user/:id/post/:postId" 中解出 { id: string; postId: string }
type ExtractParams<Route extends string> =
  Route extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<Rest>]: string }
    : Route extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {}

type Params = ExtractParams<'/user/:id/post/:postId'>  // { id: string; postId: string }

这两段代码的模式是「用 infer 从字符串里捕获取名变量」:ExtractParams 递归地吃掉路径前缀、捕获 :param 段,直到没有参数为止。把它接入 useParams<typeof route> 之类的封装,路由改一个参数名,类型立即跟随,漏改的地方编译期直接标红——这就是「字符串与类型联动」的收益。

实战四至六:泛型组件、API 返回推导与表格列类型 ​

前端框架侧还有三个高频落点。其一,Vue 3.3+ 的泛型组件(<script setup lang="ts" generic="T">)配合条件类型,可以让表格列定义与数据行的字段强关联:columns: Array<Column<T>> 且 Column<T>['prop'] 约束在 keyof T & string 内,列名写错立刻报错。其二,为请求封装推导返回类型时,用条件类型 + infer 拦截 Promise:type Unwrap<T> = T extends Promise<infer U> ? U : T,让 api.getUser() 的类型自动是 User 而不是 Promise<User>。其三,组合式函数的返回值用 as const + 映射类型保持字面量精度,避免联合类型被拓宽后丢失字面量分支。

这三处的共同心法是:类型是行为的镜像。先想清楚运行时数据的形状变化规则,再把它翻译成映射类型与条件类型,而不是凭空造类型。

沉淀心法:先写运行时,再镜像成类型 ​

回看这 6 个模式,背后其实是一套统一的思维方法:先写运行时,再镜像成类型。拿到需求先写出理想中的 JavaScript 用法——比如 emit('order:created', payload)、setFieldValue('buyer.name', value)——再看编译器需要什么约束才能把这个用法「锁死」,最后用映射类型、条件类型与 infer 把约束表达出来。反过来先造类型再迁就用法,往往会写出晦涩且脆弱的类型定义,用的人看不懂也不敢改。

沉淀层面建议配一个配套动作:把验证过的工具类型收敛进团队的类型工具包,并配上类型测试(expectTypeOf 或 tsd 这类断言库,给类型写「单测」),让类型体操的资产可测试、可复用、可演进,而不是散落在各个仓库里等人重新发明。类型测试的另一个隐性收益是升级保护:TypeScript 大版本升级后类型行为偶有变化,有断言兜底就能在 CI 里第一时间发现漂移。

避坑总结 ​

  1. 分布式条件类型是第一翻车点。条件类型作用于「裸类型参数」时会对联合类型逐成员求值:type IsNumber<T> = T extends number ? true : false 传 1 | 'a' 得到 true | false 而不是 false。想整体判断就用元组阻断:[T] extends [number];想利用分发则显式保持裸写法,两种意图要分清。
  2. 递归类型会撞深度上限。深层数组或长字符串做递归展开时,会报 Type instantiation is excessively deep。缓解手段:减少递归步数(先截断再展开)、把大联合拆成多次映射、必要时用「迭代 + 累积参数」替代纯递归。类型报错飘红不代表逻辑错,先看是不是类型展开量爆炸。
  3. infer 的位置决定协变与逆变。T extends Promise<infer U> 捕获的是协变位置;在函数参数位置(逆变)写 infer(如 T extends (cb: (v: infer U) => void) => void)会触发多候选合并成交叉类型。回调式 API 反推参数类型时,用标准「反向 infer」写法,别凭直觉改。
  4. 模板字面量组合爆炸拖慢编译。两个各有 50 个成员的联合做交叉拼接,结果类型有 2500 个成员,编辑器会明显卡顿。对大枚举先 // @ts-expect-error 精确收缩范围,或把联合拆成按需子集再参与字面量运算。
  5. any 会静默穿透类型体操。类型推导链条中混入一个 any,下游全链路推导失效且不报错。公共工具类型的入参用 unknown 收口,配合泛型约束逐步收窄;CI 里开 noImplicitAny 是底线配置。

写在最后 ​

类型体操的价值不在炫技,而在「把数据形状的约束写进编译器」:路径类型守住表单与埋点字段,模板字面量守住事件与路由协议,条件类型守住异步封装的返回值。约束写一次,收益跟随项目的整个生命周期。实践路径也简单:从一个正在手写字符串、正在漂移的声明开始,用上面 6 个模式替换它,你的类型系统就会逐步从「装饰」变成「防护网」。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。