如果你在项目里写过一把“类型工具函数”,大概率见过这样一行红字:
Type instantiation is excessively deep and possibly infinite. (2589)
我第一次撞上它,是在封装一个生成任意长度元组的工具类型时。代码逻辑我反复看了好几遍,终止条件写得好好的,递归也必然收敛,可 TypeScript 就是不认账。后来我才意识到,TS2589 这个报错的核心根本不是“你的递归有没有终止条件”,而是“编译器在展开类型的时候深度预算超支了”。这篇文章从定位过程、编译器原理讲到尾递归优化写法,最后再聊聊工程上的取舍,希望对正在跟 TS2589 搏斗的人有帮助。
1. TS2589 出现时的典型现场:先说清楚是“真无限”还是“深度爆表”
1.1 我踩坑的真实出处:类型工具库里的 Tuple 构建
当时我在给一个事件总线类型做展开,需要根据一个数字字面量类型构建出对应长度的元组类型,再借助元组把全部索引的联合类型拿下来。很自然地,我写出了下面这种递归:
type MakeTuple< T, N extends number, R extends T[] = [] > = R['length'] extends N ? R : MakeTuple<T, N, [T, ...R]>;单看这个写法,其实是带累加器参数的“递推+尾递归”形态。我在本地测试时只用MakeTuple<string, 10>这种小规模用例,编译完全正常。等接到真实业务以后,某个配置类型解析出来需要构建上百长度的元组,TS2589 就冒出来了。
这里的直接经验是:类型工具能不能跑,跟你测试时的深度强相关。你测 10 层没事,不代表 100 层没事;今天业务类型本身有一个深层嵌套的字面量,编译期就突然给你翻脸。
1.2 快速定位:把报错压缩到最小复现
遇到 TS2589 别急着改代码,第一步永远是“把报错范围缩到最小”。我常用的排查套路是这样:
- 注释法:把触发报错的类型赋值一行行注释掉,找到真正触发的那一处。TS2589 的错误位置往往不指向递归定义本身,而是指向“递归结果被使用”的地方,比如某个变量注解、某个
extends条件判断。 - 改小参数:将递归的输入从
BuildTuple<1000>改成BuildTuple<10>,如果不再报错,说明问题确实出在“深度”,而不是定义本身有循环。 - 观察报错信息尾部:当展开失败时,IDE 的悬停提示会显示出类型展开的一截结构。如果看到的是一层套一层的同一模式,比如
[string, [string, [string, ...]]],那就基本可以断定是深度问题,不是“编译器的错”,是“我们让编译器算得太深了”。
这个阶段最忌讳的是“看到一个疑似循环引用就改成 any”。先定位清楚,后面才能对症下药。
2. 编译器视角看“类型实例化深度”:为什么会有一个深度上限
2.1 类型实例化到底在干什么
很多人会把“类型递归”和“运行时递归”完全对立起来,其实两者在“展开”这件事上很像。
你写type UserId = string,这只是一个类型别名,编译器不会立刻做任何计算。但当某个地方需要检查UserId的具体结构,比如拿它去做条件判断或继承检查时,编译器就要把UserId展开成string。这个展开动作在 TypeScript 内部叫作“实例化”(instantiation)。每展开一层引用,就多一层实例化深度。
普通类型别名通常只展开一次就结束了。但泛型递归类型不一样,递归函数每次调用自身,都会产生一个新的实例化节点。比如BuildTuple在编译期判断R['length'] extends N不成立时,又会去实例化BuildTuple<N, [T, ...R]>,于是新的节点又叠了上来。
如果这个递归一直不收敛,实例化深度就会越来越大。为了避免类型系统被一个失控的递归拖死整个编译器,TypeScript 给实例化深度设了一个内部上限。一旦超过,就直接抛出 TS2589。
这里有个容易误会的点:很多人以为 TS2589 只针对“泛型递归条件类型”。其实递归映射类型、递归模板字符串类型同样会因为实例化过深而报错。因为它们本质上都是在编译期“执行计算”。
2.2 深度预算的消耗方式:条件分支、映射与模板字符串
下面这三种类型构造,都会消耗实例化深度预算:
| 类型构造 | 示例 | 消耗方式 |
|---|---|---|
| 递归条件类型 | T extends X ? A : Recursive<T> | 每次条件判断后重新实例化递归分支 |
| 递归映射类型 | { [K in keyof T]: Deep<T[K]> } | 每层映射都生成一个新的对象类型 |
| 递归模板字符串 | S extends `${infer H}${infer R}` ? F<R> : ... | 每次匹配都要解析并拼接字符结构 |
举个例子,interface TreeNode { children: TreeNode[] }这种“递归描述一个数据结构”的写法,编译器并不会去展开无限深,因为它只是描述形状,不涉及“计算”。而BuildTuple这种类型工具,是实打实地在编译期构建一个数组结构,每一步都会把上一层的结果继续传入下一层,实例化深度自然就叠加起来了。
这就像递归函数和递归数据结构的区别:前者消耗调用栈,后者只是定义了一个可以无限延伸的引用关系。TypeScript 对这两种递归的态度完全不同。理解这一点,是排查 TS2589 的基础。
3. 尾递归优化的真正原理:TS 4.5+ 是怎么“作弊”的
3.1 递归条件类型为什么在 4.5 之前会被严格限制
在 TypeScript 4.5 之前,递归条件类型是一个非常危险的东西。编译器在检查T extends X ? ... : Recursive<T>时,需要不断实例化自身来判断条件是否成立。每一次递归调用都相当于给“调用栈”压了一帧,递归多深,栈就多深,编译器为此设置了很紧的深度限制来保护自己。
这导致一个尴尬的结果:类型系统里写递归条件类型,一旦层数稍多,立刻 TS2589。社区里流行过一些绕开方式,比如用数组长度来模拟计数、用对象索引来模拟迭代,本质上都是“把一次递归展开变成多次浅层展开”,非常繁琐。
3.2 尾递归写法的形态:累加器与直接返回
如果只是简单地把递归函数改成“尾递归+累加器”,效果可能会比你想的还好。原因在 TypeScript 4.5 的发布说明里写得很清楚:编译器对递归条件类型做了一类优化——当递归调用处于条件分支的直接返回位置时,它不会为每一层递归都分配一个“新的类型栈帧”,而是把后续实例化当作同一层级的迭代继续处理。
拿字符串拆成字符数组来说。非尾递归的写法是这样:
type StringToCharsNaive<S extends string> = S extends `${infer Head}${infer Rest}` ? [Head, ...StringToCharsNaive<Rest>] : [];这段代码的问题很明显:递归调用返回后还要再做一次数组展开[Head, ...]。编译器必须等最内层的递归算完,再一层层把结果“组装”起来。每一层递归的结果都被外层包裹,实例化深度与递归次数成正比,几十层就可能爆掉。
换成尾递归加累加器:
type StringToCharsTail< S extends string, Acc extends string[] = [] > = S extends `${infer Head}${infer Rest}` ? StringToCharsTail<Rest, [...Acc, Head]> : Acc;这里的关键变化在于:
- 中间结果由
Acc参数携带,不再依赖每次递归返回后进行组装。 - 递归调用
StringToCharsTail<Rest, [...Acc, Head]>直接作为条件分支的最终结果,后面没有其他类型操作等着它。 - 编译器检查到这种“直接返回递归调用”的形式时,可以不再保留当前层的“临时帧”,把下一轮递归当成同一个检查通道里的迭代继续。
这跟运行时递归函数改成尾递归是同一个思路:公共语言运行时会尝试复用栈帧,TS 则在类型实例化时复用“检查状态”。这也是很多人把这类写法直接叫“类型层尾递归优化”的原因。
3.3 优化生效边界:不是所有递归都吃这碗饭
TS 4.5 的优化并不是万能的。编译器给出的优惠只针对“递归条件类型”中处于分支返回位置的递归调用。下面这几种情况,优化不会生效:
- 递归调用被包在映射类型里,比如
{ [K in keyof T]: Deep<T[K]> },每层都要生成新对象,仍然会大量消耗实例化深度。 - 递归调用被包在模板字符串拼接里,比如
`${Head}${Deep<Rest>}`,编译器还是得等待内层结果完成拼接。 - 同一个条件分支里存在多个递归调用,优化策略会变得非常保守。
所以实战中判断“这个递归能不能改成尾递归”,核心就看一句话:递归调用是不是这个分支里的最后一个动作,并且结果是否被直接返回。只要递归结果还要再经过一层类型运算,优化就会失效。
4. 实际改造:从会爆的写法到能扛住更深递归的写法
4.1 Tuple 构建:最基础的尾递归范式
先从最经典的错误例子开始。大多数人第一次遇到 TS2589,都是因为想按长度构建元组。我之前犯过错,正确的尾递归写法应该长这样:
type BuildTuple< N extends number, Acc extends unknown[] = [] > = Acc['length'] extends N ? Acc : BuildTuple<N, [...Acc, unknown]>; type T10 = BuildTuple<10>; // T10 = [unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown]这里有几个细节值得展开。
第一,终止条件用Acc['length'] extends N,而不是N extends Acc['length']。因为数组的length属性是一个数字字面量类型,在递归过程中它会不断递增,最终精确命中目标N。两种写法的判定方向不同,行为差异很大。如果写成N extends Acc['length'],目标值N是字面量类型时可能永远匹配不上,从而无限递归。
第二,N必须是一个具体数字字面量,不能是宽泛的number。如果传入BuildTuple<number>,Acc['length'] extends number恒为真,递归会在第一步直接返回空数组,虽然不报错,但结果根本不是你想要的。这个坑非常隐蔽,在泛型工具链里尤其容易踩到,因为调用方传进来的可能就是一个未约束的number泛型参数。
第三,追加元素用[...Acc, unknown],比[unknown, ...Acc]更符合“不断向尾部追加”的直觉,也保证了最终元组元素的顺序与插入顺序一致。TS 在处理数组字面量展开时,会复制整个Acc结构,深度一大确实会慢。基于同样的原因,BuildTuple<10000>之类的极端用例即便编译过了,也可能让 IDE 卡上好几秒。不过对比起原来的非尾递归写法,已经是天壤之别了。
4.2 字符串拆分与数字计算:把“最后一步”从递归中拆出来
字符串拆分是最适合用尾递归优化的场景之一。比如下面这个把字符串变成字符元组的工具:
type StrToTuple<S extends string, Acc extends string[] = []> = S extends `${infer Head}${infer Tail}` ? StrToTuple<Tail, [...Acc, Head]> : Acc; type Demo = StrToTuple<'hello'>; // Demo = ["h", "e", "l", "l", "o"]这里S extends \${infer Head}${infer Tail}`的匹配规则要解释一下:当模板字符串条件类型里同时出现两个 infer 占位时,TypeScript 会让第一个 infer 尽量匹配最少字符,所以Head会是单个字符,Tail` 拿剩余部分。正是这个特性,让字符串一个一个字符拆开成为可能。
数字计算的优化思路其实也是“把最后一步拆出去”。比如做一个Add:
type BuildTupleUpTo< N extends number, Acc extends unknown[] = [] > = Acc['length'] extends N ? Acc : BuildTupleUpTo<N, [...Acc, unknown]>; type Add<A extends number, B extends number> = [...BuildTupleUpTo<A>, ...BuildTupleUpTo<B>]['length']; type Sum = Add<2, 3>; // Sum = 5这里的递归部分只负责“把 N 展开成 N 个元素的数组”,计算加法时不再递归,而是把两个展开结果拼起来取长度。递归本身保持纯尾递归,最终取长度的动作放在递归外面,从而避免“递归过程中还要维护结果操作帧”的问题。
我踩这类坑时最大的感受是:写类型层计算,要主动把“构建中间结构”和“消费中间结构”两件事分开。不要写出[...BuildTupleUpTo<A>, ...BuildTupleUpTo<B>]['length']中间夹递归的写法,而是先在递归里把数组准备好,再把数组交给最终的类型运算。
4.3 嵌套对象类型映射:尾递归不好使时的工程取舍
有一种递归很难通过尾递归来救,就是“深层对象映射”。比如常见的DeepMap:
type DeepMapValue<T, F> = T extends (...args: any[]) => any ? T : T extends object ? { [K in keyof T]: DeepMapValue<T[K], F> } : F;逻辑很清楚:遍历对象所有属性,把叶子类型映射成F。但每一层递归都会生成一个全新的对象类型,编译器必须先把这一层对象的结构建好,才能继续深入下一层。TS 4.5 的尾递归优化对这种“映射类型包裹递归调用”的形式几乎没有帮助,因为递归结果不是直接返回,而是被套进了一个{ [K in keyof T]: ... }的结构里。真实业务里如果有一个 JSON 字面量嵌套了十几层,这种写法是很容易触发 TS2589 的。
这种情况我会怎么处理?
第一优先是限制层数。如果数据源是可控的,比如接口约束就是最多三层嵌套,那类型层面最好也显式写出三层展开,不要把“无限递归”当作默认方案。运行时数据有界,类型就有界;反过来说,如果运行时都不知道数据有多深,类型系统也很难凭空给出一个无限深的结构。
第二优先是避免“让编译器在类型里做数据解析”。如果这个深层对象结构来自 JSON Schema、YAML 配置或者接口响应,我倾向于用一个脚本把相关类型直接生成成.d.ts文件。类型本身是静态的“数据”,让编译器每次编译都去递归遍历一遍,既不必要也不划算。
第三才是考虑写复杂的展开技巧。比如“递归结果先推断出来再拍平一层”这种操作,虽然有时能绕过 TS2589,但可读性和可维护性都比较差。作为临时方案可以,不推荐做成团队的公共工具。
5. 边界情况与工程经验:哪些场景不该用类型递归硬扛
5.1 真正的无限递归与“看起来无限”的收敛递归
TS2589 有时确实是由真正的无限递归导致的。判断方法和运行时递归一样:看递归参数有没有收敛。
下面几种情况的特征截然不同:
| 代码 | 结论 |
|---|---|
type BadLoop<T> = T extends string ? BadLoop<T> : never; | 真无限:T始终不变,递归永远不会结束 |
type Converging<T extends string> = T extends \${infer _}${infer Rest}` ? Converging : 0;` | 收敛:Rest每次都比T短,最终会触底 |
interface TreeNode { children: TreeNode[] } | 不是计算:只是描述递归数据结构,编译时不会展开 |
判断技巧也很简单:把递归参数换成一个小值试一下。Converging<'a'>立刻不报错,说明定义本身是收敛的;如果换成任何输入都报错,那大概率是定义里出现了非收敛路径。
还有一个容易混淆的点:TS 对“递归类型别名”和“循环类型别名”的报错不一样。type Loop<T> = Loop<T>会被识别成“Type alias 'Loop' circularly references itself”,也就是 TS2456。而 TS2589 更常出现在“条件分支里的递归实例化”场景。看到不同的错误码,排查方向也要随之调整。
5.2 类型计算与类型描述的区别
前面反复提到了这两个词:类型计算和类型描述。我觉得这是理解 TS2589 最重要的一层认知。
类型描述,比如interface TreeNode { children: TreeNode[] },是在告诉 TypeScript“数据可能长成什么样”。这种递归可以是无限的,因为编译器只是记录了一个引用关系,不会真的把children展开到最后一层。它对应的是运行时数据结构,天然支持递归。
类型计算,比如BuildTuple、StrToTuple、DeepMapValue,是在让编译器在编译期不断“生成新类型”。这类递归必须考虑编译器内部资源,递归多少次就消耗多少实例化深度。它对应的是运行时函数调用,天然受调用栈限制。
很多人看到 interface 里可以无限递归对象,就以为泛型递归条件类型也可以随便写。实际上这两件事的底层约束完全不同。写类型工具的时候,每写一个递归,都要先问问自己:这到底是在“描述结构”还是在“执行计算”?如果是后者,就必须认真对待深度问题。
5.3 长期维护建议
最后给三条工程上的建议,都是我在后续项目里验证过有用的。
第一,尽量把 TS 版本维持在 4.5 以上,我目前常用的项目基本都在 5.x。很多在 4.5 之前“怎么优化都会爆”的写法,升级之后直接用尾递归形式就能稳定跑通。版本越老,遇到 TS2589 的阈值就越低。
第二,给复杂类型工具写回归测试。类型工具也是代码,也会因为后来的需求改动被改坏。用tsd或者 Vitest 的expectTypeOf给关键的类型计算结果写断言,至少保证下一次重构不会悄悄把递归参数改得不收敛。
第三,控制递归宽度。尾递归优化能解决“深度”问题,但解决不了“宽度”问题。如果一个递归类型在每一层都生成很大的中间对象,比如真正的BuildTuple<10000>这种级别,即使不报 TS2589,编译时间也会变得很难看。类型计算应该以“能完成业务目标”为限,不要没事就在类型层搞重型运算。
最后说一点个人心得。TS2589 这个报错现在对我来说已经不是一个“编译器报错”,更像是一句提醒:你的类型程序可能正在做一件运行时不该做的事,或者在做一个运行时做起来很贵的事。类型系统是为开发体验服务的,不应该为了炫技把它逼到崩溃边缘。遇到深度问题,先想清楚这个递归是不是必要的;如果必要,就用尾递归+累加器把它改好。把“尾递归+累加器”写进肌肉记忆,以后写类型工具会从容很多。