《TypeScript高级编程》3.1 类型实例化开销与测量

本节把「类型算得多贵」变成可测量的数字:讲清编译器内部的一次类型实例化究竟是什么、泛型与映射类型何时被真正求值,再逐个拆解 tsc 的 extendedDiagnostics 与 generateTrace 输出,教你读懂 Instantiations、Types 与 Check time 三者的关系。最后给出实例化爆炸的四类来源与 CI 基线回归脚本,读完你能定位并量化真实项目的类型性能热点。

本节目标:读完这一节,你能用自己的话说清「类型实例化」在编译器内部到底做了什么,知道一个泛型类型在什么时刻才被真正求值;能跑通 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>;   // 命中缓存,不再新增

三点值得注意:

  1. 实例化的产物是一个新类型对象,它有内存成本,也被计入 Types 指标。
  2. 类型参数相同就命中缓存。B3 与 B1 参数一致,编译器直接复用,所以实例化次数不是「引用次数」而是「不同参数组合的次数」。
  3. 实例化可以级联。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符号表规模,约等于声明数量突增通常意味着依赖膨胀而非类型写坏
NodesAST 节点数主要反映代码量,不是类型问题
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

三个要点:

  1. 阈值留波动空间。编译器版本升级、依赖小版本更新都会带来几个百分点的自然浮动,卡死数字会造成大量误报。
  2. 门禁只对主分支或 PR 生效,本地开发时不要拦,否则会诱导开发者写 any 来「通过检查」。
  3. 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 尾递归消除与深度限制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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