本节目标:读完这一节,你能列出编译器限制递归类型的三道闸门(单次深度、总次数、尾递归预算)各自的量级;能区分
Type instantiation is excessively deep在「太深」与「太多」两种成因下的表现;能分清尾位置优化与递归终止条件,并用累加器把Reverse、Filter这类反转式递归改写成可消除形态;能验证互递归的实际预算,并在改写、加计数器、改代码生成三条出路之间做出判断。
3.2 尾递归消除与深度限制
上一节我们用 --generateTrace 把类型开销变成了数字,但有一类问题不会表现为「慢」,而是直接编译失败:
error TS2589: Type instantiation is excessively deep and possibly infinite.
写类型体操的人几乎都被它拦过。这个错误的意思不是「类型写错了」,而是「类型展开得太多,编译器主动放弃」。要绕过它,先得知道编译器到底设了几道闸门,以及哪一道被撞了。
三道闸门
编译器的 checker 用三个内部约束来防止类型求值无限进行下去(具体数值属实现细节,不同版本略有差异,下面给出量级):
| 闸门 | 量级 | 作用 |
|---|---|---|
| 单次实例化深度 | 约 100 层 | 防止一个类型在展开过程中越钻越深 |
| 总实例化次数 | 约 500 万次 | 限制一次检查链的实例化工作量,并非全项目统计值上限 |
| 尾递归预算 | 约 1000 次 | 对可被消除的尾递归单独放宽的额度 |
三道闸门对应两种报错体验:
// 撞第一道:展开太深,栈式递归
type Repeat<T, N extends number, Acc extends T[] = []> =
Acc["length"] extends N ? Acc : Repeat<T, N, [...Acc, T]>; // 这是尾递归,走第三道
type Nest<T, N extends number, Acc extends unknown[] = []> =
Acc["length"] extends N ? T : { v: Nest<T, N, [...Acc, 0]> };
type A = Nest<number, 5>; // 递归结果被对象包装,五层嵌套
// 类型可能按需展开;单独声明更深的 Nest 别名不保证立刻触发限制
// 撞第二道:每个分支都产生新实例化,总量失控
type Combinator<T extends string> = `${T}a` | `${T}b` | `${T}c` | `${T}d`;
type Level1 = Combinator<"">;
type Level2 = Combinator<Level1>; // 16 种
type Level3 = Combinator<Level2>; // 64 种
type Level4 = Combinator<Level3>; // 256 种,逐层 ×4
第二种情况往往没有明确报错,只是编译越来越慢、内存越来越高,直到某天 CI 直接 OOM。所以 3.1 的测量与这一节的深度限制是同一枚硬币的两面:报 2589 是显性的失控,不报 2589 但 Instantiations 飙升是隐性的失控。
什么算尾递归:优化形态与终止条件
TS 4.5 发布说明 说明:条件类型的分支直接返回另一个条件类型时,编译器可以避免部分中间实例化。它采用启发式优化,不是对任意递归的承诺。
- 看返回位置:递归结果若还要参与元组拼接、联合或字符串拼接,通常无法直接消除。
- 看求值规模:分发到大联合会制造额外工作,尾位置不保证开销恒定。
- 看终止条件:输入递减或计数器趋近上限是设计责任;重新构造输入并不自动取消尾递归优化。
- 看版本实测:互递归也可能被消除,不能仅凭 A 调 B、B 调 A 判断会超限。
先看最经典的反例与正例:
// 非尾递归:结果被 [...Reverse<R>, H] 包住,必须等内层返回后再拼接
type Reverse<T extends unknown[]> =
T extends [infer H, ...infer R] ? [...Reverse<R>, H] : [];
// 尾递归:递归调用就是整个分支的结果,待办事项通过 Acc 参数下传
type ReverseFast<T extends unknown[], Acc extends unknown[] = []> =
T extends [infer H, ...infer R] ? ReverseFast<R, [H, ...Acc]> : Acc;
type R1 = ReverseFast<[1, 2, 3]>; // [3, 2, 1]
type Build<N extends number, A extends 1[] = []> =
A["length"] extends N ? A : Build<N, [...A, 1]>;
type R2 = ReverseFast<Build<900>>; // TS 5.9.3 下长度 900 元组通过
尾位置是观察的核心:看递归调用外面还有没有「包装」。[...Reverse<R>, H] 里 Reverse<R> 被一个元组构造包住了,编译器无法「就地」把参数改掉再循环,只能真的压一层栈。
终止条件常被忽略。下面的 Chunk 位于尾位置,但输入不断增长,永远无法命中空元组分支:
// ❌ 没有趋近终止条件,最终会耗尽预算
type Chunk<T extends unknown[]> =
T extends [] ? [] : Chunk<[T]>;
// ✅ 消费输入,保证对有限元组终止
type Walk<T extends unknown[]> =
T extends [unknown, ...infer R] ? Walk<R> : [];
互递归同样要区分「能否优化」与「是否终止」,下文用类型断言实测。
累加器:把「返回后拼接」改成「参数下传」
尾递归改写的统一套路叫累加器(accumulator):把原本要等递归返回后才做的拼接,改成当作参数往下传。凡是「先递归到底、再自下而上拼装」的写法,几乎都能这样改写。
// 非尾递归的 Filter
type Filter<T extends unknown[], U> =
T extends [infer H, ...infer R]
? H extends U
? [H, ...Filter<R, U>]
: Filter<R, U>
: [];
// 尾递归版本
type FilterFast<T extends unknown[], U, Acc extends unknown[] = []> =
T extends [infer H, ...infer R]
? H extends U
? FilterFast<R, U, [...Acc, H]>
: FilterFast<R, U, Acc>
: Acc;
type Odd = FilterFast<[1, 2, 3, 4, 5], 1 | 3 | 5>; // [1, 3, 5]
注意累加器版本天然保序,而非尾递归版本靠 [H, ...Result] 的「头插」也能保序——两者的结果一致,差别只在求值方式。下面这张表是常见改写对照:
| 需求 | 非尾递归写法 | 尾递归写法(累加器) |
|---|---|---|
| 反转 | [...Rev<R>, H] | Rev<R, [H, ...Acc]> |
| 过滤 | H extends U ? [H, ...F<R, U>] : F<R, U> | F<R, U, [...Acc, H]> |
| 字符串替换 | ${A}${Repl<Rest>} | Repl<Rest, ${Acc}${A}> |
| 取路径 | 每层拼 K. 前缀 | 前缀作为参数下传 |
| 扁平化 | [...Flat<H>, ...Flat<R>] | 逐项 Push 进 Acc |
字符串累加是最容易被忽略的一类,因为字符串没有 [H, ...R] 这种直观的拆解语法:
// 非尾递归:替换全部,结果在递归返回后拼接
type ReplaceAll<S extends string, From extends string, To extends string> =
S extends `${infer A}${From}${infer B}` ? `${A}${To}${ReplaceAll<B, From, To>}` : S;
// 尾递归:已处理的前缀放进 Acc
type ReplaceAllFast<
S extends string,
From extends string,
To extends string,
Acc extends string = "",
> = S extends `${infer A}${From}${infer B}`
? ReplaceAllFast<B, From, To, `${Acc}${A}${To}`>
: `${Acc}${S}`;
type S1 = ReplaceAllFast<"a-b-c-d", "-", "_">; // "a_b_c_d"
改写不是免费的:签名变长、多一个默认参数、可读性下降。所以判据是「撞到 2589 且确实需要更大深度时再改」,而不是一上来就写累加器版本。3.1 讲过的测量在这里同样适用——先确认这个类型真的在热点上。
互递归:用版本实测代替猜测
两个条件类型互相调用,不代表一定无法消除尾递归。下面的例子在 TS 5.9.3 下可以处理 900 次递进;超过内部预算仍会失败。
type IsEven<N extends number, Acc extends unknown[] = []> =
Acc["length"] extends N ? true : IsOdd<N, [...Acc, 0]>;
type IsOdd<N extends number, Acc extends unknown[] = []> =
Acc["length"] extends N ? false : IsEven<N, [...Acc, 0]>;
type Expect<T extends true> = T;
type E1 = Expect<IsEven<10>>;
type E2 = Expect<IsEven<900>>;
type E3 = Expect<IsOdd<901>>;
压平成单递归可以改善可读性,但必须携带奇偶状态;只累加元组最后返回 true 的版本会把奇数也判断为偶数。
type Parity<N extends number, Acc extends unknown[] = [], Even extends boolean = true> =
Acc["length"] extends N ? Even : Parity<N, [...Acc, 0], Even extends true ? false : true>;
type IsEvenFast<N extends number> = Parity<N>;
type E4 = Expect<IsEvenFast<900>>;
type E5 = Expect<IsEvenFast<901> extends false ? true : false>;
输入范围也必须声明:本例只接受非负整数字面量。负数或小数永远不会等于元组长度,应在公共工具中限制输入或加预算。
主动设限:用计数器元组封顶
比「撞报错」更稳妥的做法是主动给递归设一个上限,让它在超限时干净地返回一个哨兵类型,而不是抛 2589。标准工具是一个递减计数器:
// Prev[3] = 2,Prev[0] = never:到 0 就终止
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type Paths<T, D extends number = 6> = [D] extends [0]
? never
: T extends object
? {
[K in keyof T & string]-?: K | `${K}.${Paths<T[K], Prev[D]>}`;
}[keyof T & string]
: never;
interface Api {
user: { id: number; profile: { name: string; city: string } };
post: { title: string };
}
type ApiPaths = Paths<Api>;
// "user" | "user.id" | "user.profile" | "user.profile.name"
// | "user.profile.city" | "post" | "post.title"
[D] extends [0] 在零预算处终止;用方括号包裹是为了阻止分发(2.1 条件类型与分发
讲过的技巧),保证计数器归零时判定一次就结束。改小 D 的默认值就能在不改调用方的前提下收窄规模。
遇到自引用结构(链表、树、图)时,计数器几乎是唯一可靠的方案,因为这类结构的展开天然没有自然终止条件:
interface TreeNode {
value: number;
children: TreeNode[]; // 自引用
}
type DeepReadonlySafe<T, D extends number = 4> = [D] extends [0]
? T
: T extends string | number | boolean | null | undefined
? T
: T extends readonly (infer E)[]
? readonly DeepReadonlySafe<E, Prev[D]>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonlySafe<T[K], Prev[D]> }
: T;
// 递归映射可能延迟展开;计数器明确限定要处理的深度
type FrozenTree = DeepReadonlySafe<TreeNode>; // 深度 4 干净终止
与运行时的类比,以及一处关键差异
类型层的尾递归消除容易让人联想到语言运行时里的尾调用优化(TCO),但两者的性质完全不同:
| 维度 | 运行时 TCO | 类型层尾递归消除 |
|---|---|---|
| 适用范围 | 语言规范定义的通用优化 | 编译器对特定形态的定向优化 |
| 触发条件 | 任意尾调用 | 满足启发式条件的尾位置条件类型递归 |
| 失效表现 | 栈溢出 | TS2589 或静默变慢 |
| 能否依赖 | 视引擎而定 | 可依赖,但有版本差异 |
不能指望「把类型写成尾递归就一定安全」:预算从几十提到约 1000 是有条件的、也是实现相关的。真正可靠的做法是「尾递归 + 显式计数器」双保险——尾递归争取更大额度,计数器保证一定有出口。
诊断流程
撞上 2589 时,按下面的顺序排查,通常两三步就能定位:
npx tsc --noEmit 2>&1 | head -20 # 看报错落在哪个类型上
npx tsc --noEmit --generateTrace ./trace # 生成 types.json
node analyze-trace.mjs ./trace/types.json | head -10 # 复用 3.1 的聚合脚本
// 临时把可疑类型换成具体参数,二分定位是哪一步开始超限
type Probe1 = Paths<Api, 2>; // 通过?
type Probe2 = Paths<Api, 4>; // 通过?
type Probe3 = Paths<Api, 6>; // 报错?→ 临界点在 4~6 之间
判断该走哪条路:
| 现象 | 结论 | 动作 |
|---|---|---|
| 递归写在尾位置但仍报错 | 无终止分支、超预算或联合过大 | 检查出口、减少规模 |
| 递归结果被包装 | 非尾递归 | 引入累加器 |
| 需要有限展开自引用结构 | 需要明确处理边界 | 加计数器元组封顶 |
| 深度确实需要上千层 | 类型层不适合 | 改用代码生成 |
| 只有个别文件报错 | 局部问题 | 该文件单独放宽或加逃生舱 |
实战:安全的路由参数提取
把这一节的手法合起来,写一个有深度上限、尾递归、且对空串安全的路由参数提取工具:
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
// 单递归 + 累加器 + 计数器封顶
type ParamsOf<
R extends string,
D extends number = 6,
Acc extends string = never,
> = [D] extends [0]
? Acc
: R extends `${string}:${infer Rest}`
? Rest extends `${infer Name}/${infer Tail}`
? ParamsOf<`/${Tail}`, Prev[D], Acc | Name>
: ParamsOf<"", Prev[D], Acc | Rest>
: Acc;
type P1 = ParamsOf<"/user/:id/post/:slug">; // "id" | "slug"
type P2 = ParamsOf<"/user/:id/:a/:b/:c/:d/:e/:f/:g">; // 深度 6 截断,仍是合法结果
type P3 = ParamsOf<"/static/path">; // never
三个设计决策都来自本节:
- 单递归:用
Rest extends ... ? ... : ...在同一类型内继续走,而不是拆成两个互调的类型。 - 累加器
Acc:已找到的参数名通过参数下传,避免在返回时拼接联合。 - 计数器
D:超过 6 段就停止收集,示例演示截断;用于 API 契约时应返回超限标记或拒绝该输入,避免少收参数产生错误的类型保证。
延伸阅读:递归类型的工程案例可参考既有专题 /typescript-advanced-types/ 与 /typescript-type-level-programming/ ;类型性能的整体治理可看 /typescript-build-performance-optimization/ 。
小结
- 编译器用三道闸门限制递归类型:单次实例化深度(约 100 层)、总实例化次数(约 500 万)、尾递归预算(约 1000 次)。
TS2589有两种面貌:太深(栈式递归撞深度)与太多(分支爆炸撞次数);后者往往不报错,只表现为变慢,属于 3.1 的测量范畴。- 尾递归优化关注条件类型分支的直接返回形态;递减输入与预算保证终止,不能与优化条件混为一谈。
- 累加器是尾递归改写的统一套路:把「返回后拼接」改成「参数下传」,元组、字符串、路径提取都适用。
- 互递归也可能被优化;用固定编译器版本测试,压平时必须保留原来的状态与语义。
- 比撞报错更稳的是主动设限:用
Prev计数器元组给递归封顶,自引用结构尤其需要。 - 尾递归消除是编译器的定向优化,与语言运行时的通用 TCO 不是一回事;可靠做法是「尾递归 + 计数器」双保险。
- 出路有三条:改写为尾递归、加计数器封顶、改用代码生成。选择依据是「深度真的需要多大」与「输入是否可控」。
到这里,「类型的开销」这件事我们已经能从两个角度看清:3.1 告诉我们它有多贵,3.2 告诉我们它的天花板在哪。剩下最后一个问题——当一份泛型已经又慢又深,该怎么把它改小而不改坏。下一节 3.3 复杂泛型的重构手法 给出一套可操作的七种手法与评审清单。
阅读导航:上一节:3.1 类型实例化开销与测量 · 下一节:3.3 复杂泛型的重构手法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。