本节目标:读完这一节,你能写出带终止条件的递归类型来描述 JSON、树结构与深层修饰;能解释
Type instantiation is excessively deep(2589)与Type alias circularly references itself(2456)分别是怎么触发的;能用累加器把非尾递归改写成尾递归以获得更大的深度预算;并且能用tsc --extendedDiagnostics与--generateTrace找到类型层面的性能热点,按清单把它压下去。
10.3 递归类型与类型性能治理
前两节我们把工具类型和模板字面量类型都过了一遍。你会发现它们有个共同特征:会递归。Awaited 递归解 Promise,ExtractRouteParams 递归切路径,DeepPartial 递归下钻对象。
递归让类型系统具备了「处理任意深度结构」的能力,但也把编译器的计算量交到了我们手里。这一节要解决两件事:递归怎么写才正确,以及递归怎么写才不会把编译拖垮。
递归类型:自我引用加终止条件
递归类型就是在定义里引用自己。JavaScript 里写递归函数要两个要素——调用自身、有一个不再调用的出口;类型层面完全一样。
最经典的例子是 JSON:
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
这里 Json 在数组和对象两个分支里引用了自己。终止条件是「原始类型分支」:一旦下钻到 string、number、boolean、null,就不再展开。
const data: Json = {
name: "Ada",
tags: ["admin", "ops"],
profile: { active: true, score: 99, meta: null },
};
const bad: Json = { fn: () => {} };
// 类型 '() => void' 不能赋值给类型 'Json'。ts(2322)
注意 TypeScript 允许类型别名直接自引用(只要引用发生在对象、数组、联合等「延迟位置」)。写成下面这种立即求值的形式就会报 2456:
type Loop = Loop;
// 错误:Type alias 'Loop' circularly references itself. ts(2456)
深层修饰:把上一节的浅层工具变深
上一节说过 Partial / Readonly 是浅层的。递归版本长这样:
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
用一个嵌套配置验证:
interface Config {
server: {
host: string;
port: number;
tls: { enabled: boolean; cert?: string };
};
retries: number;
}
type DraftConfig = DeepPartial<Config>;
// {
// server?: { host?: string; port?: number; tls?: { enabled?: boolean; cert?: string } };
// retries?: number;
// }
type FrozenConfig = DeepReadonly<Config>;
declare const cfg: FrozenConfig;
cfg.server.tls.enabled = false;
// 错误:Cannot assign to 'enabled' because it is a read-only property. ts(2540)
但这两个实现有两个隐藏问题,必须知道:
T extends object会把数组和函数也判成object,于是数组被映射成「键是数字索引的对象」。要排除它们得先判断:
type DeepPartialSafe<T> = T extends (...args: any[]) => any
? T
: T extends readonly unknown[]
? T
: T extends object
? { [K in keyof T]?: DeepPartialSafe<T[K]> }
: T;
- 递归没有深度上限,遇到自引用结构(链表节点、树)会一直展开直到触发 2589。
深度报错:2589 与 2456
这两个错误信息在类型编程里几乎必然会遇到:
Type instantiation is excessively deep and possibly infinite. ts(2589)
Type alias 'X' circularly references itself. ts(2456)
| 错误码 | 触发原因 | 典型场景 |
|---|---|---|
| 2589 | 递归展开层数超过编译器上限 | 无深度限制的 DeepPartial、超长字符串的 Split |
| 2456 | 类型别名直接、立即地引用自己 | type A = A、type A = A | string |
| 2321 | 类型别名被用作自身泛型参数且立即求值 | type A<T> = A<T[]> 之类 |
非尾递归的条件类型,编译器默认的实例化深度上限是 50 层左右(内部常量,不同版本略有差异);一旦超限就抛 2589。这不是「类型写错了」,而是「类型太大」,需要改写法。
尾递归消除与累加器
TypeScript 4.5 引入了一个重要优化:对尾递归的条件类型做消除,把深度预算从几十提到 1000 层。
什么算尾递归?条件是:递归调用出现在分支的最终结果位置,且结果不再被包装。
// ❌ 非尾递归:结果被包进 [...R, H],必须等递归返回后再拼
type Reverse<T extends unknown[]> =
T extends [infer H, ...infer R] ? [...Reverse<R>, H] : [];
// ✅ 尾递归:递归调用就是整个分支的结果,额外数据交给累加器参数
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]
累加器(accumulator)是尾递归的标准套路:把「还没做完的事」从「返回后拼接」改成「作为参数传下去」。同一个套路在 Reduce、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]
代价是类型签名变长、可读性下降。建议只在真的撞到 2589 时才改写成尾递归,不要一上来就写累加器版本。
给递归加深度上限
另一种更朴素的治理手段:主动限制展开层数,用元组当计数器。这是社区里处理「路径类型」这类高危递归的标准做法:
// 计数器:Prev[3] = 2,Prev[2] = 1,Prev[1] = 0,Prev[0] = never
type Prev = [never, 0, 1, 2, 3, 4, 5];
type Paths<T, D extends number = 5> = [D] extends [never]
? 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 [never] 用方括号包裹是为了阻止分发(第 9 章讲过的技巧),确保计数器到 0 时干净地终止。把 D 的默认值从 5 改成 3,就能在不改调用方的情况下收窄展开规模。
测量:先量再治
类型性能问题和运行时性能问题一样,不能凭感觉优化。tsc 提供了两个测量开关:
npx tsc --noEmit --extendedDiagnostics # 概览:一行行指标,最快拿到全局画像
npx tsc --noEmit --generateTrace ./trace # 详细追踪:产出可被 Chrome Tracing 打开的文件
--extendedDiagnostics 的输出长这样:
Files: 128
Lines of Library: 39432
Lines of Definitions: 58210
Nodes: 412877
Symbols: 76302
Types: 52310
Instantiations: 812345
Memory used: 198432K
Assignability cache size: 41220
Total time: 4.21s
最该盯的是 Instantiations(类型实例化次数)。 它大致等于「编译器为了求值你的类型,创建了多少个类型对象」。经验阈值:
| Instantiations | 判断 |
|---|---|
| < 100 万 | 正常 |
| 100 万 ~ 300 万 | 偏高,值得查 |
| > 300 万 | 会明显影响编辑器补全与 CI 编译,必须治 |
--generateTrace 产出的 trace.json 用 Chrome 的 chrome://tracing 打开,可以看到具体哪个文件、哪个类型贡献了最多实例化,比只看总数精确得多。
常见反模式与治理清单
反模式一:交叉类型层层叠加。 A & B & C & D 的求值代价随成员数超线性增长,且很难定位。
// ❌ 几十个交叉,每次使用都要重新求值
type Everything = A & B & C & D & E & F & G & H & I & J;
// ✅ 拆成具名接口,让编译器能缓存
interface Everything extends A, B, C, D, E, F, G, H, I, J {}
反模式二:大对象 + as const + 深映射。 一个几百字段的常量表被 DeepReadonly 包一层,实例化次数会瞬间飙升。
反模式三:模板字面量类型的联合爆炸。 第 10.2 节讲过组合数是乘积级的,几十个成员乘以几十个键就是上千。
反模式四:在热路径上重复推导同一个复杂类型。 同一段推导写在十个函数签名里,编译器就要求值十次。
对应的治理清单:
| 手段 | 说明 |
|---|---|
用 interface 代替 type 做对象 | 接口是延迟求值 + 缓存的,类型别名会立即求值 |
| 拆出中间别名 | 给重复出现的推导起个名字,让编译器复用求值结果 |
| 限定递归深度 | 用计数器元组给递归类型设上限 |
| 改写为尾递归 | 只在撞到 2589 时做,换取 1000 层预算 |
| 减少交叉类型 | 优先用 extends 组合接口 |
用 satisfies 代替显式注解 | 让编译器沿用字面量类型,避免额外的宽化与重算 |
用 @ts-expect-error 兜底 | 极端类型上留一个逃生舱,而不是让整个文件卡住 |
一个真实工程示例:深只读配置
把本节所有手段合起来,写一个安全、有限深度、可测量的深只读工具:
type Primitive = string | number | boolean | null | undefined;
type DeepReadonlySafe<T, D extends number = 8> = [D] extends [never]
? T
: T extends Primitive
? T
: T extends (...args: any[]) => any
? 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;
declare const appConfig: DeepReadonlySafe<Config>;
appConfig.server.host = "0.0.0.0";
// 错误:Cannot assign to 'host' because it is a read-only property. ts(2540)
appConfig.server.tls.cert = "/etc/cert.pem";
// 错误:Cannot assign to 'cert' because it is a read-only property. ts(2540)
这个版本相比前面的朴素写法做了四件事:
- 先挡原始类型,避免对叶子节点做无意义的映射。
- 排除函数,防止把函数当成对象映射掉。
- 对数组特判,保留元组/数组语义而不是变成索引对象。
- 带深度计数器,遇到自引用结构时能干净终止,而不是抛 2589。
写完这个类型后,第一件事是跑一次 --extendedDiagnostics 记下 Instantiations 基线;改完实现再跑一次对比。类型性能优化和运行时优化一样,靠的是前后差值,不是直觉。
延伸阅读:编译期性能的更多话题,可参考既有专题文章 /typescript-build-performance-optimization/ 与 /typescript-project-architecture-tsconfig/ 。
小结
- 递归类型 = 自我引用 + 终止条件;
Json是教科书式的例子,终止条件落在原始类型分支上。 - 类型别名在对象、数组、联合等延迟位置自引用是合法的;写成
type Loop = Loop这种立即求值形式会报 2456。 DeepPartial/DeepReadonly是工具类型的深层版本,但必须处理数组、函数与自引用结构这三类特例。- 非尾递归的条件类型深度上限约几十层,超限报 2589;尾递归 + 累加器能把预算提到 1000 层。
- 用元组计数器(
Prev+[D] extends [never])给递归类型主动设深度上限,是比撞报错更稳妥的做法。 - 类型性能要看数据:
tsc --extendedDiagnostics看Instantiations,--generateTrace定位到具体文件与类型。 - 治理优先级:
interface优于type、拆中间别名、限定深度、少用交叉、必要时用satisfies与逃生舱。
到这里,第 10 章「类型体操与工具类型」就结束了。三节连起来看:工具类型是标准库给我们的现成积木,模板字面量类型把字符串维度补上,递归类型与性能治理则让我们在放大能力的同时守住编译速度这条底线。下一章我们会回到工程实践,讲模块系统与模块解析——类型写得再好,也要能被正确地组织和加载。
阅读导航:上一节:10.2 模板字面量类型 · 下一节:11.1 ES 模块与模块解析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。