TypeScript 类型编程进阶:条件类型、infer 与模板字面量类型实战
年初写映射类型那篇笔记的结尾,我给自己留了个尾巴:映射类型解决的是"同一个模型的不同视角",但它只能对已有的结构做变换,回答不了"如果类型是这样,那结果类型应该是那样"这种带条件的问题。这半年配置平台和权限系统的迭代里,这类问题冒出来好几次,把我推着走进了条件类型和 infer 的地界。这篇把验证过的部分整理出来——和年初一样,所有例子我都在编辑器里逐个悬停确认过推导结果,类型编程这个东西,不亲手验证的理解基本都是错的。
条件类型:类型层面的三元表达式
条件类型的语法长得就像三元表达式:T extends U ? X : Y。extends 在这里读作"可以赋值给",T 能赋值给 U 就取 X,否则取 Y。
1type IsString<T> = T extends string ? true : false; 2 3type A = IsString<'hello'>; // true 4type B = IsString<42>; // false
单看这个例子会觉得没什么用,它的价值要放进泛型函数里才显出来。配置平台里有个真实场景:一个取配置项的函数,配置值可能是原子值也可能是一组选项,返回类型想跟着输入走:
1type ConfigValue<T> = T extends { options: infer O } ? O : T; 2 3declare function resolveConfig<T>(item: T): ConfigValue<T>; 4 5const a = resolveConfig({ options: ['A', 'B'] }); // string[] 6const b = resolveConfig('dark'); // 'dark'
这里已经用上 infer 了,先按下不表。要点是:条件类型让返回类型成为输入类型的函数,调用方不需要断言、不需要重载列表,类型自己算出来。
写到这里得对比一下老办法。同样的需求以前我会写函数重载——两个签名各管一种入参。重载在入参形态只有两三种、且彼此差异大的时候依然是更直白的选择,报错信息也比条件类型友好(条件类型出错时那一屏 T extends ... ? ... : ... 的展开,新同事看了会沉默)。条件类型的优势在于入参和返回值之间存在"可计算的关系"时:重载列表是枚举,条件类型是公式,形态组合一多,枚举就爆炸,公式不会。我的取舍是两三个重载能说清的绝不上条件类型。
infer:在模式匹配里挖一个洞
infer 是我这半年觉得最值得吃透的关键字。它只能出现在条件类型的 extends 子句里,作用是在类型的模式匹配中声明一个"待推断的洞":如果 T 匹配这个形状,把洞里的东西掏出来给我用。
内置的 ReturnType 就是最典型的应用,实现只有一行:
1type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; 2 3type Fn = () => Promise<string>; 4type R = MyReturnType<Fn>; // Promise<string>
读法是:如果 T 长得像一个函数,那么它的返回值位置上是什么,就把什么绑定到 R。同理可以从任何结构位置上掏东西——数组元素、Promise 的 resolve 值、元组的头尾:
1type ElementOf<T> = T extends (infer E)[] ? E : never; 2type Awaited2<T> = T extends Promise<infer V> ? V : T; 3type Head<T> = T extends [infer H, ...unknown[]] ? H : never;
项目里真正救过场的一次是接口层。我们的请求函数都长成 (params) => Promise<ApiResponse<Data>> 的形状,列表页想直接拿到 Data 的类型给表格列用,以前的做法是再 export 一个类型出来、两边手动保持同步。有了 infer 就可以从函数本身反解:
1type ApiData<F> = F extends (...args: any[]) => Promise<ApiResponse<infer D>> ? D : never; 2 3type GoodsItem = ApiData<typeof fetchGoodsList>[number];
请求函数的返回类型改了,表格列的类型跟着变,类型的单一事实来源从"两个手写副本"收敛成了"函数签名本身"。这正是年初那篇笔记里"可推导优于手写"原则的延伸——映射类型推导结构,infer 推导位置。
TS 4.7 之后 infer 还能带 extends 约束,infer S extends string 这样写,省掉了以前推断出来还要再套一层条件判断的啰嗦。我们项目今年从 4.9 升到了 5.1,这个写法可以放心用。
infer 还有个冷门但一撞上就懵的行为:同一个 infer 变量出现在多个位置时,推断结果取决于这些位置的型变方向。协变位置(比如对象属性、返回值)推出联合类型,逆变位置(函数参数)推出交叉类型。文档里那个经典例子我在编辑器里验证了一遍:
1type Foo<T> = T extends { a: infer U; b: infer U } ? U : never; 2type T1 = Foo<{ a: string; b: number }>; // string | number 3 4type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } 5 ? U : never; 6type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; 7// string & number,也就是 never
第一个好理解:U 要同时容纳 a 和 b 的类型,取并集。第二个要想一下:参数位置是逆变的,"能同时传给这两个函数的参数"必须既是 string 又是 number,取交集。这个行为甚至被社区玩出了花——著名的 UnionToIntersection 就是拿逆变位置当工具,把联合类型硬转成交叉类型。业务代码里用不上这种技巧,但读懂它之后,一些第三方库类型源码里的"黑魔法"就祛魅了。
分布式条件类型:最容易挨打的陷阱
条件类型有个不显眼但影响巨大的行为:当被检查的类型参数是"裸的"(naked type parameter),传入联合类型时,条件类型会分发到联合的每个成员上分别计算,再把结果并起来。
1type ToArray<T> = T extends any ? T[] : never; 2 3type X = ToArray<string | number>; 4// 不是 (string | number)[] 5// 而是 string[] | number[]
很多时候分发正是你想要的,内置的 Exclude 全靠它才成立:
1type MyExclude<T, U> = T extends U ? never : T; 2type Rest = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
分发之后每个成员单独判断,匹配上的变 never,never 在联合类型里自动消失——整个机制严丝合缝。但它坑起来也是真坑。我在权限系统里写过一个判断"是否为布尔开关"的类型,结果撞了个正着:
1type IsBool<T> = T extends boolean ? '开关' : '非开关'; 2 3type T1 = IsBool<boolean>; // 我以为是 '开关'
悬停一看,T1 确实是 '开关',但当 T 是从别处流过来的 boolean | undefined 时,结果变成了 '开关' | '非开关'——因为 boolean 本身就被当作 true | false 参与分发,undefined 再走另一支。更隐蔽的是 never:never 被视为空联合类型,分发到零个成员上,整个条件类型直接返回 never,你写的两个分支一个都不会走:
1type Test = IsBool<never>; // never,不是 '非开关'
需要关掉分发的时候,办法是让类型参数"不裸"——最惯用的写法是两边都包一层元组:
1type IsBoolStrict<T> = [T] extends [boolean] ? '开关' : '非开关'; 2 3type T2 = IsBoolStrict<boolean | undefined>; // '非开关',整体判断 4type T3 = IsBoolStrict<never>; // '开关',[never] 是个正常的元组类型
我的经验是:写条件类型时先问自己一句"联合类型进来该整体判断还是逐个判断",答案是整体就立刻加方括号。这个陷阱排查起来极费时间,因为报错的位置往往在很远的下游。
分发机制想通之后,几个内置工具类型的行为也跟着通了。NonNullable<T> 就是 T extends null | undefined ? never : T 的分发应用;权限系统里我拿分发干过一件实事:从一个大的权限动作联合类型里,按前缀筛出某个模块的动作子集——
1type Permission = 'goods:read' | 'goods:write' | 'order:read' | 'order:cancel'; 2 3type OfModule<T, M extends string> = T extends `${M}:${string}` ? T : never; 4type GoodsPermission = OfModule<Permission, 'goods'>; 5// 'goods:read' | 'goods:write'
分发让每个联合成员单独过一遍模板匹配,匹配不上的成员归于 never 自动消失。这里分发恰好是想要的行为,和前面 IsBool 的坑是同一个机制的两副面孔——分布式条件类型不是缺陷,是默认行为,坑与不坑取决于你有没有意识到它在发生。
模板字面量类型:把字符串纳入类型系统
模板字面量类型是 4.1 就有的特性,但我到今年才真正在项目里用出价值。它让字符串的拼接和拆解都发生在类型层面,配合 infer 做字符串的模式匹配。
第一个实战是路由参数提取。我们中后台的路由路径是 '/goods/:id/sku/:skuId' 这种形式,跳转函数以前的 params 类型是 Record<string, string>,拼错参数名要到运行时才炸。用递归的模板字面量匹配可以直接从路径字符串里把参数名抠出来:
1type ExtractParams<Path extends string> = 2 Path extends `${string}:${infer Param}/${infer Rest}` 3 ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } 4 : Path extends `${string}:${infer Param}` 5 ? { [K in Param]: string } 6 : {}; 7 8type P = ExtractParams<'/goods/:id/sku/:skuId'>; 9// { id: string; skuId: string } 10 11declare function navigate<Path extends string>( 12 path: Path, 13 params: ExtractParams<Path> 14): void; 15 16navigate('/goods/:id/sku/:skuId', { id: '1', skuId: '2' }); // OK 17navigate('/goods/:id/sku/:skuId', { id: '1' }); // 报错:缺 skuId
第一支匹配"中间的参数",第二支匹配"结尾的参数",递归拼合。这段类型我写了三稿才对,但换来的是全项目的路由跳转都有了参数名和数量的编译期检查,值。
第二个实战是事件名拼接。埋点系统的事件名规范是"模块_动作",以前靠文档和 code review 约束,现在靠类型:
1type Module = 'goods' | 'order' | 'user'; 2type Action = 'view' | 'click' | 'submit'; 3 4type EventName = `${Module}_${Action}`; 5// 'goods_view' | 'goods_click' | ... 共 9 个成员 6 7declare function track(event: EventName, payload?: object): void; 8 9track('order_submit'); // OK 10track('order_sumbit'); // 编译期报错,手误当场现形
模板字面量遇到联合类型会做笛卡尔积展开,两个小联合拼出九个合法事件名,字符串拼写错误这类最低级也最难查的 bug 被类型系统整个接管了。配套的 Uppercase/Lowercase/Capitalize/Uncapitalize 四个内置工具在做 on${Capitalize<EventName>} 这类回调命名映射时也顺手。
模板字面量和映射类型还有个合体形态:键重映射(key remapping),映射类型的 as 子句里可以对 key 做模板字面量变换。年初的笔记里映射类型只能"保留或过滤"原有的 key,加上 as 之后,key 本身也能加工了。给配置模型批量生成 getter 方法签名就是一行的事:
1type Getters<T> = { 2 [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K]; 3}; 4 5type Config = { theme: string; pageSize: number }; 6type ConfigGetters = Getters<Config>; 7// { getTheme: () => string; getPageSize: () => number }
K & string 那一下是因为 keyof 可能混进 symbol,模板字面量只接受 string 系的类型,交叉一下把 symbol 滤掉。as 子句里返回 never 还能顺手做 key 过滤,比先 Pick 再映射少一层嵌套。这块严格说是映射类型的进阶残篇,补在这里,年初那篇就不回去改了。
递归深度:类型系统不是图灵机的免费午餐
路由参数那个递归类型引出了下一个话题:递归是有限额的。TS 对条件类型的递归深度有硬限制(实例化深度大约 50 层,尾递归优化的条件类型放宽到千级),撞上就是那句著名的 "Type instantiation is excessively deep and possibly infinite"。
我真撞过一次。想写一个把嵌套对象所有 key 拍平成 'a.b.c' 路径联合的类型,给配置平台的表单联动用,对象一深、可选字段一多,编辑器直接开始转圈,然后报深度超限。查了资料才知道 4.5 之后对"尾递归形态"的条件类型有优化——递归调用必须是整个类型的最后一步,中间不能再包别的构造。用字符串拆分做例子,两种形态长这样:
1// 非尾递归:递归结果还要参与外层的拼接 2type Split<S extends string> = 3 S extends `${infer H}.${infer T}` ? [H, ...Split<T>] : [S]; 4 5// 尾递归:把累加结果作为参数带着走,递归调用就是最终结果 6type SplitTail<S extends string, Acc extends string[] = []> = 7 S extends `${infer H}.${infer T}` 8 ? SplitTail<T, [...Acc, H]> 9 : [...Acc, S];
改成尾递归形态后能多撑不少层,但这已经是明确的信号:类型演算的复杂度在指数爬升,编辑器的响应速度在替所有同事买单。那个拍平类型最后我限制了递归三层,够用,且 IDE 不卡。
类型编程该在哪里刹车
半年用下来,我给自己划了几条停手线,比任何具体技巧都重要。
第一条:类型编程只能描述编译期已知的约束,运行时进来的数据它一个字节都管不着。接口返回、URL 参数、localStorage 里的东西,写再精巧的类型也只是"我相信它长这样"的注释。这些边界上该做的是运行时校验,我们现在用 zod 定 schema,再用 z.infer 把静态类型反推出来——校验和类型共享同一个事实来源,比"手写类型 + 裸 as 断言"诚实得多:
1import { z } from 'zod'; 2 3const goodsSchema = z.object({ 4 id: z.string(), 5 price: z.number().nonnegative(), 6 tags: z.array(z.string()).default([]), 7}); 8 9type Goods = z.infer<typeof goodsSchema>; 10 11const goods = goodsSchema.parse(await fetchGoods(id)); 12// 到这一行之后,goods 的类型才是"被验证过的事实"
这个月配置平台有个 bug 就是最好的注脚:某个老配置项在数据库里存的是字符串 "20",前端类型写的 number,所有类型检查都通过,页面上 pageSize + 1 变成了 "201"。类型编程写得再深,也防不住三年前入库的一条脏数据。判断标准很简单:数据是代码里长出来的,用类型编程;数据是边界外进来的,先上运行时校验。
第二条:可读性优先于精巧。类型体操写到三层嵌套条件加递归,两个月后连作者都要重新推演一遍。我现在的做法是复杂类型必须拆成有名字的中间类型、必须配一组用 satisfies 或注释断言写的类型测试用例:
1type _test1 = ExtractParams<'/a/:x'> satisfies { x: string } ? true : true;
(更顺手的其实是社区那种 Expect<Equal<A, B>> 断言工具,几行就能自己实现。)类型也是代码,也会回归,也需要测试。
第三条:给团队用的类型和给自己用的类型标准不同。navigate 那种基础设施类型,复杂一点值得,因为使用方完全感知不到复杂度,只享受检查;散落在业务代码里的类型,宁可写得笨一点、直白一点。上周评审一个 PR,同事在业务组件里写了个三层条件类型去区分四种弹窗形态,我建议他改成四个显式命名的 interface 加一个联合——类型行数多了一倍,但下一个接手的人五秒钟能看懂。类型编程是杠杆,杠杆应该架在被复用最多的支点上,而不是均匀地涂满整个代码库。
顺便说一句工具链:验证这些类型的过程中,TS Playground 比本地项目好用——随手开一个 5.1 的环境,粘进去看推导,不污染项目代码;配合编辑器里逐段悬停,基本能替代"console.log 调试"在类型世界里的位置。类型编程的反馈回路越短,克制的分寸反而越容易守住,因为你能很快看到一个类型写复杂之后的真实推导成本。
年初那篇笔记的结尾说,映射类型让"同一个模型的不同视角可推导"。现在可以接上后半句了:条件类型和 infer 让"类型随输入而变"也可推导,模板字面量把字符串也拉进了推导的范围。但推导算得再远,也接不住数据库里存了三年的那个字符串 "20"——类型说它是 number,页面上 pageSize + 1 照样能变成 "201"。类型体操能省的是"随输入而变"这类编译期的活,接口返回、URL 参数这些运行时进来的东西,还是得靠 zod 那道校验去担。这半年的尾巴,算是收上了。