《TypeScript高级编程》3.2 尾递归消除与深度限制

本节拆解编译器为递归类型设下的三道闸门:单次实例化深度、总实例化次数与尾递归预算,说明 2589 报错在「太深」与「太多」两种情形下的不同成因与排查手法。重点讲透尾位置、终止条件和预算的区别,用累加器把反转式递归改写成可消除形态,并通过实测说明互递归也可能被优化。读完你能在撞上深度上限时判断该改写、该加计数器,还是该改用代码生成。

本节目标:读完这一节,你能列出编译器限制递归类型的三道闸门(单次深度、总次数、尾递归预算)各自的量级;能区分 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 发布说明 说明:条件类型的分支直接返回另一个条件类型时,编译器可以避免部分中间实例化。它采用启发式优化,不是对任意递归的承诺。

  1. 看返回位置:递归结果若还要参与元组拼接、联合或字符串拼接,通常无法直接消除。
  2. 看求值规模:分发到大联合会制造额外工作,尾位置不保证开销恒定。
  3. 看终止条件:输入递减或计数器趋近上限是设计责任;重新构造输入并不自动取消尾递归优化。
  4. 看版本实测:互递归也可能被消除,不能仅凭 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

三个设计决策都来自本节:

  1. 单递归:用 Rest extends ... ? ... : ... 在同一类型内继续走,而不是拆成两个互调的类型。
  2. 累加器 Acc:已找到的参数名通过参数下传,避免在返回时拼接联合。
  3. 计数器 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 复杂泛型的重构手法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes