本节目标:读完这一节,你能用自己的话说清「类型实例化」在编译器内部到底做了什么,知道一个泛型类型在什么时刻才被真正求值;能跑通
tsc --extendedDiagnostics与--generateTrace并读懂Instantiations、Types、Check time这三个关键指标;能识别出映射类型、交叉类型、模板字面量与巨型字面量这四类实例化爆炸源;最后能用一份基线脚本把类型性能写进 CI 门禁。
3.1 类型实例化开销与测量
2.3 类型级数据结构与图灵完备
结束时的结论是:TypeScript 的类型系统是图灵完备的。图灵完备意味着能算,但「能算」从来不等价于「算得便宜」。一个 DeepPartial<Config> 可能只花几微秒,也可能让 tsc 多跑三秒、让编辑器的补全卡上半拍。
这一节要做的,是把「类型算得贵不贵」从主观感受变成可以读出来的数字。所有治理动作都建立在测量之上——先量,再治,最后用同一个指标验证治理有效。
实例化到底是什么
编译器的 checker 在检查每个文件时,会为源码里的类型构造出内部的 Type 对象。对于不依赖参数的具名类型,这个过程只做一次;但对于泛型,每次传入不同的类型参数,都要算出一个新的类型。这个「把类型参数代入类型体、求出结果」的动作,就叫实例化(instantiation)。
interface Box<T> { value: T; meta: string }
type B1 = Box<number>; // 实例化 1 次:生成 { value: number; meta: string }
type B2 = Box<string>; // 实例化 2 次:生成 { value: string; meta: string }
type B3 = Box<number>; // 命中缓存,不再新增
三点值得注意:
- 实例化的产物是一个新类型对象,它有内存成本,也被计入
Types指标。 - 类型参数相同就命中缓存。
B3与B1参数一致,编译器直接复用,所以实例化次数不是「引用次数」而是「不同参数组合的次数」。 - 实例化可以级联。
Box<Box<Box<number>>>会依次实例化内三层,一次外层引用可能触发多次内部实例化。
条件类型与映射类型更贵,因为它们的求值结果取决于类型参数的结构,编译器必须真的「展开」才能知道结果:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<number>>; // number,需要匹配 Promise<infer U>
type B = Unwrap<Promise<Promise<string>>>; // 只剥一层,结果仍是 Promise<string>
Unwrap 的每一次求值都要走一遍条件判断、构造 infer U、再做一次赋值检查。这类类型的实例化成本远高于普通接口,而它们的数量在真实项目里往往是最难控的。
惰性求值:为什么 interface 比 type 便宜
要理解开销,必须知道求值时机。TypeScript 对两类声明的处理策略完全不同:
| 声明形式 | 求值策略 | 何时展开 |
|---|---|---|
interface Foo { ... } | 惰性 | 只有真的读取成员时才解析 |
type Foo = { ... } | 立即 | 声明处即构造类型对象 |
type Foo<T> = ... | 惰性(泛型) | 传入类型参数时才实例化 |
type A = B & C | 立即 | 交叉类型在声明处就要归并成员 |
这解释了一条老生常谈的优化建议:用 interface 描述对象形状,用 type 描述联合与推导。接口是延迟求值且可被增量缓存的,而一个几百行的巨型交叉类型在每次引用时都可能被重新归并。
三个测量开关
tsc 内置了三个诊断开关,全部与运行时无关,只测编译期:
npx tsc --noEmit --diagnostics # 只看耗时汇总(Build/Check/Transform 等)
npx tsc --noEmit --extendedDiagnostics # 完整指标:实例化、类型、符号、内存
npx tsc --noEmit --generateTrace ./trace # 产出 trace.json 与 types.json 供深入分析
--extendedDiagnostics 的输出大致长这样:
Files: 214
Lines of Library: 39432
Lines of Definitions: 61204
Nodes: 528341
Symbols: 94112
Types: 66830
Instantiations: 1238440
Memory used: 268310K
Assignability cache size: 52104
Identity cache size: 428
Subtype cache size: 926
Strict subtype cache size: 308
I/O Read time: 0.06s
Parse time: 0.51s
ResolveModule time: 0.14s
ResolveTypeReference time: 0.01s
Program time: 0.92s
Bind time: 0.22s
Check time: 3.14s
transformTime time: 0.31s
Total time: 4.63s
逐行读法:
| 指标 | 含义 | 什么时候该警觉 |
|---|---|---|
Instantiations | 类型实例化次数 | 首要指标,超过 100 万值得查 |
Types | 存活到当前时刻的类型对象数 | 与 Instantiations 同向增长 |
Symbols | 符号表规模,约等于声明数量 | 突增通常意味着依赖膨胀而非类型写坏 |
Nodes | AST 节点数 | 主要反映代码量,不是类型问题 |
Assignability cache size | 可赋值性判定缓存条目数 | 与实例化同向,可作为交叉验证 |
Check time | 类型检查耗时 | 用户能直接感知的那一段 |
Memory used | 检查器占用内存 | 过高时编辑器会频繁 GC、补全变卡 |
Check time 才是用户感知的那一段,但它是结果不是原因。原因在 Instantiations:实例化次数翻倍,检查时间基本同步翻倍。所以调优时的因果链是「降 Instantiations → 降 Check time」,而不是反过来盯着秒数瞎猜。
用 trace 定位到具体类型
只看总数只能知道「有问题」,--generateTrace 才能告诉你「问题在哪」。它产出两个文件:
trace.json:可用 Chrome 的chrome://tracing打开,看时间轴上的阶段与热点函数。types.json:类型实例化的明细记录,是定位的真正入口。
types.json 是一份行分隔的 JSON(每行一个对象),可以直接用 jq 或一段小脚本聚合。下面的脚本统计「哪个文件贡献的实例化最多」:
// analyze-trace.mjs:node analyze-trace.mjs ./trace/types.json
import { readFileSync } from "node:fs";
const file = process.argv[2] ?? "./trace/types.json";
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
const byFile = new Map();
for (const line of lines) {
const ev = JSON.parse(line);
// kind: "instantiate" | "recursiveType" | "interface" | ...
if (ev.kind !== "instantiate") continue;
const loc = ev.location?.[0];
if (!loc?.file) continue;
byFile.set(loc.file, (byFile.get(loc.file) ?? 0) + 1);
}
const top = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
for (const [f, n] of top) console.log(String(n).padStart(8), f);
真实输出示例(一个把 DeepReadonly 用在巨型配置对象上的项目):
412887 node_modules/.pnpm/zod@3.23.8/node_modules/zod/lib/types.d.ts
184203 src/schema/generated-api.d.ts
96311 src/types/deep-readonly.ts
52104 src/types/route-paths.ts
拿到这份排行后,治理动作就具体了:前三名里第一名是依赖(动不了),第二名是生成的类型(改生成器),第三、四名才是自己的类型工具。没有 trace,你会在自己的文件里改一整天,而真正的成本在 node_modules 里。
四类实例化爆炸源
把社区反复踩坑的案例归纳一下,爆炸源基本落在四类:
第一类:大联合上的映射类型。 映射类型对联合做分发时,代价随成员数线性甚至超线性增长。
type EventName = "a" | "b" | "c"; // ...实际可能有 200 个成员
type Handlers = { [K in EventName]: (payload: K) => void };
// 每个成员一个属性,200 个成员 = 200 次属性实例化
type Mapped = { [K in EventName]: Record<K, string> }; // 再乘一层
第二类:层层叠加的交叉类型。 A & B & C & D 在每次需要成员列表时都要重新归并,而且归并结果不进 interface 那样稳定的缓存。
type Everything = A & B & C & D & E & F & G & H & I & J;
interface Everything2 extends A, B, C, D, E, F, G, H, I, J {}
// 接口组合的成员归并只做一次,且可被增量复用
第三类:模板字面量类型的乘积爆炸。 组合数是乘积级的,几十个前缀乘几十个后缀就是上千个字符串字面量类型。
type Method = "get" | "post" | "put" | "delete";
type Resource = `/api/${string}` | `/admin/${string}`;
type Route = `${Method} ${Resource}`; // 4 × N × 2 种组合
第四类:巨型 as const 字面量再套深映射。 一个几千字段的常量表,套上 DeepReadonly 或 DeepPartial,实例化次数会瞬间上一个数量级。
const registry = { /* 约 3000 个键 */ } as const;
type Frozen = DeepReadonly<typeof registry>; // 逐字段下钻,代价与字段数同阶
四类爆炸源有一个共同点:它们在源码里看起来都很短。这正是类型性能问题难发现的原因——代码审查看不出代价,只有测量能。
建立预算与回归门禁
类型性能治理必须像运行时性能一样写进 CI,否则一次「顺手加个工具类型」就能把基线推高几十万次实例化而无人察觉。
做法是固定一份基线数字,在 CI 里对比。下面脚本先采集当前实例化次数,再与「基线 × 1.15」的上限比较:
set -euo pipefail
current=$(npx tsc --noEmit --extendedDiagnostics 2>/dev/null \
| awk '/^Instantiations:/ {print $2}')
baseline=1200000
limit=$(( baseline * 115 / 100 )) # 允许 15% 的自然波动
echo "instantiations: ${current} (baseline ${baseline}, limit ${limit})"
if [ "${current}" -gt "${limit}" ]; then
echo "类型实例化超出预算,请先跑 --generateTrace 定位热点"
exit 1
fi
三个要点:
- 阈值留波动空间。编译器版本升级、依赖小版本更新都会带来几个百分点的自然浮动,卡死数字会造成大量误报。
- 门禁只对主分支或 PR 生效,本地开发时不要拦,否则会诱导开发者写
any来「通过检查」。 Instantiations之外再挂一个Check time上限,防止「实例化没涨但每个实例化都变贵」的情况。
案例:从 480 万降到 92 万
一个真实的中后台项目,Instantiations 480 万、Check time 11.2s。trace 显示前三名分别是路由类型、表单 schema 与一个自研的 DeepPartial。三步治理:
// 治理前:路由参数用递归模板字面量全量展开
type RouteParams<P extends string> = P extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof RouteParams<`/${Rest}`>]: string }
: P extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
// 治理后:路由表先写成具名联合,映射只在需要的少数路由上做
type KnownRoute = "/user/:id" | "/post/:slug" | "/order/:orderId";
type ParamsOf<R extends KnownRoute> = R extends `${string}:${infer P}` ? { [K in P]: string } : {};
// 治理前:表单 schema 每层都用交叉叠加
type FormSchema = BaseFields & ValidationRules & UIFlags & A11yAttrs & I18nMeta;
// 治理后:拆成具名接口,用 extends 组合,成员归并只做一次
interface BaseFields { name: string; email: string }
interface ValidationRules { minLen: number; maxLen: number }
interface FormSchema extends BaseFields, ValidationRules {}
// 治理前:DeepPartial 无深度上限,配置对象自引用时无限展开
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
// 治理后:加深度计数器 + 先挡原始类型,避免对叶子做无意义映射
type Prev = [never, 0, 1, 2, 3, 4, 5];
type DeepPartialSafe<T, D extends number = 4> = [D] extends [never]
? T
: T extends string | number | boolean | null | undefined
? T
: T extends readonly unknown[]
? T
: T extends object
? { [K in keyof T]?: DeepPartialSafe<T[K], Prev[D]> }
: T;
三步之后:Instantiations 从 480 万降到 92 万,Check time 从 11.2s 降到 2.8s,编辑器补全延迟从约 900ms 降到 200ms 以内。注意每一步改动都没有削弱类型安全——治理的对象是「求值方式」,不是「类型强度」。
这一节我们建立了测量的方法论。但有些类型问题的表现不是「慢」,而是直接报错:Type instantiation is excessively deep and possibly infinite。这背后是编译器为递归设下的深度与次数上限,下一节 3.2 尾递归消除与深度限制
就专门讲这套限制规则与绕过它的正规手法。
延伸阅读:编译期性能的更多工程话题,可参考既有专题 /typescript-build-performance-optimization/ 、/typescript-project-architecture-tsconfig/ 与 /typescript-language-service-editor-plugins/ ;类型设计层面的取舍可看 /typescript-advanced-types/ 与 /typescript-type-level-programming/ 。
小结
- 实例化是「把类型参数代入类型体求出结果」的动作;每个不同的类型参数组合都会产生一次实例化,参数相同则命中缓存。
- 条件类型与映射类型的实例化成本远高于普通接口,因为编译器必须真的展开才能求出结果。
- 求值时机决定成本:
interface惰性、type别名立即、泛型别名在传入参数时实例化、交叉类型在声明处归并。 - 三个测量开关:
--diagnostics看耗时、--extendedDiagnostics看全指标、--generateTrace定位到具体文件与类型。 - 核心指标是
Instantiations,它是原因;Check time与编辑器延迟是结果。因果链永远是「先降实例化,再降耗时」。 - 四类爆炸源:大联合上的映射、层层交叉、模板字面量乘积、巨型
as const再套深映射——它们共同的特征是源码看起来很短。 - 治理必须进 CI:用基线加 15% 波动阈值做门禁,并同时约束
Instantiations与Check time两个指标。 - 真实案例:路由类型具名化、交叉改接口组合、
DeepPartial加深度计数器,可把 480 万实例化降到 92 万,且不损失类型强度。
下一节我们处理测量之后最常见的那类硬故障:递归类型撞上编译器的深度上限。
阅读导航:上一节:2.3 类型级数据结构与图灵完备 · 下一节:3.2 尾递归消除与深度限制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。