
1. 八秒编译现场巨型联合类型如何一点点拖垮全项目1.1 一个典型的巨型联合类型长什么样先还原一个我印象很深的现场。某个中后台权限系统路由表拆得非常细页面多、权限点多、每个路由还要带上自己的参数和菜单配置。为了把这些东西全部类型化团队里有人写出了这样一个文件// types/route.ts export type RouteName | home | user-center | user-list | user-detail | role-list | role-detail // ... 后面还有 300 多个 | pay-setting | pay-log;紧接着为了从路由名推导参数类型又出现了一个几百行的条件类型链export type RouteParamsK extends RouteName K extends user-detail ? { id: string } : K extends role-detail ? { id: number; tab?: overview | members } : K extends blog-detail ? { id: number; tab?: overview | comments } // ... 一个近乎无限下坠的条件链 : Recordstring, never;这个项目刚开始只有二十来个路由时一切正常。后面模块越接越多RouteName 的成员数超过 300路径参数又牵扯到几十个字段联合类型之后情况崩了tsc冷编译从最初的 1.2 秒涨到 8 秒以上增量 watch 也卡到 3 秒VS Code 里鼠标悬停在一个变量上要转半天圈写代码时自动补全有时直接空白好几秒。如果你也遇到过类似症状大概率就是同一个病根巨型联合类型 条件类型展开把 TypeScript 编译器的类型实例化开销推到了失控状态。1.2 编译慢和 IDE 卡顿是同一问题的两条泄露路径一开始我们怀疑是不是机器性能不行或 VS Code 插件装太多。但后来发现IDE 卡顿和tsc编译慢完全是同一件事的两面。tsc慢是因为类型检查阶段要对整个 program 做全量检查。一个 300 多成员的联合类型只要被多个文件引用每次tsc --noEmit都会把所有关联类型重新实例化一遍。而 IDE 卡顿也是同一个引擎在跑只是触发方式不同。VS Code 的 TypeScript server 会在你输入、保存、悬停的每个节点做增量类型检查。而像悬停提示、自动补全这种操作必须先把某个表达式解析成可读类型。如果这个表达式碰巧涉及一个几百人的联合类型编辑器就要现场把整个联合类型展开、整理、打印成提示文字。展开的结果既不能完全缓存又没法跳过CPU 就在那一两秒里被吃满了。所以这类问题的本质不是编辑器慢而是类型设计让编译器不断做大量多余的遍历和展开。理清这一点后面的优化方案才会走对方向。1.3 先给 tsc 做体检extendedDiagnostics 与 trace 分析动手改代码之前一定要先量化问题。tsc提供了一个隐藏的参数可以输出编译过程的核心耗时数据npx tsc --noEmit --extendedDiagnostics跑完之后你会看到类似下面这样的输出Files: 512 Nodes: 8,402,113 Identifiers: 2,914,442 Symbols: 1,021,390 Types: 38,472 Instantiations: 4,712,351 Memory used: 2,067,890K Assignability cache size: 4,102,331 Time: 8.2s这里面最值得盯的是Instantiations类型实例化次数和Assignability cache size可赋值性缓存大小。正常情况下一个 500 文件左右的项目Instantiations 在几十万到百万出头都比较合理。飙到几百万甚至上千万说明类型展开严重失控。如果还想定位具体是哪个文件在烧时间可以用 TypeScript 自带的 trace 功能npx tsc --noEmit --generateTrace ./trace-output跑完后用 Chrome 打开https://typescript.azurewebsites.net/analyze-trace/index.html把trace-output目录上传就能看到每个文件占用的编译时间。timeSelf指标最高的那几个文件通常就是巨型联合类型和条件类型链的藏身之处。看完这些数据我基本就确定了问题方向单纯把联合类型这里的成员删掉几个没用得从类型建模方式上做修改。2. 慢的根子在编译器的“遍历”与“展开”三种主要成本2.1 assignability 遍历联合成员越多“CPU 彩票”刮得越多联合类型为什么是性能杀手根子在于 TypeScript 做可赋值性检查时对联合类型采取的是逐个匹配策略。你可以把联合类型想成一张大名单。每当编译器需要确认一个值能不能赋给RouteName类型时它就拿着这个值把大名单里 300 多个名字挨个比一遍。只比一次还好问题是代码里到处都在做这种赋值函数参数、对象字面量推导、接口兼容性判断、泛型约束……每次赋值检查都要把这张大名单从头到尾刮一遍。如果只是线性扫描300 个成员还能忍。真正致命的是联合类型和对象类型混在一起时的组合遍历。比如你有两个三四十人的联合类型还需要互相做兼容比较或者把联合类型放进泛型约束里反复推断编译器要做的匹配次数会呈乘积式地往上翻。我在那个项目的诊断里就发现许多字段的联合类型只有二三十个成员但它们被嵌在几百个路由相关的交叉检查里最后 assignability cache 突破了 400 万条。2.2 分布条件类型把联合成员交给 extends 时的指数展开如果说普通联合是线性慢那么条件类型叠加联合就是指数级爆炸。TypeScript 有一个很贴心的设计当条件类型左侧的泛型参数是裸类型参数时编译器会把联合类型拆成一个个成员分别套进条件分支再合并结果。从语义上来说这确实让ToolTypeT这种工具类型能逐个处理联合成员。但代价是每次拆分都会复制一份分支逻辑。举个最典型的例子type IsStringT T extends string ? true : false; // 编译器实际会展开成 5 个判断分支 type Result IsStringa | 1 | true | null | undefined;单看一层还不算大。可如果IsStringT的分支里又嵌套另一个条件类型第二个条件类型里又嵌套第三个那一层层的展开就会变成乘法叠加。项目里那个RouteParamsK的条件链就是这么炸的type RouteParamsK extends RouteName K extends user-detail ? SomeComplexTypeK : K extends role-detail ? AnotherComplexTypeK // ... 300 层条件分支每次用到RouteParamsK时编译器要顺着K的所有联合成员逐个尝试匹配前面的字面量类型一旦命中还要继续展开分支里的工具类型。这么多层嵌套下来Instantiations 直接干到 400 万就一点都不奇怪了。2.3 缓存为什么失效IDE 悬停提示与 quick info 的生成负担有读者可能问TypeScript 不是有缓存机制吗为什么改了文件之后IDE 还是每次都重新算这个问题我从实际观察上得出的结论是缓存对稳定的对象类型很有效但对动态展开的联合类型经常失效。TypeScript 会把一些已经完成的 assignability 检查结果缓存下来以便下次相同比较直接命中。但条件类型展开依赖具体的泛型入参只要泛型入参里有联合类型每次展开产生的中间类型都是新实体缓存键几乎无法复用。另外IDE 的悬停提示还有一层额外开销它要把类型打印出来。打印一个结构化的对象类型很快但打印一个还在懒展开状态的条件类型时编译器必须强制跳过 lazy 计算走完整条求值路径才能拿到最终形态的字段。这就等于每次鼠标移上去都要现场算一遍几百万次实例化的类型不卡才怪。3. 写代码层面的四招优化把类型从“算一遍”改成“查一次”3.1 策略一用映射结构 keyof 替代手写判别联合明确了慢的根子之后我把最大的那个RouteName手写联合类型整体重构成了映射结构。改动前export type RouteName | home | user-center // ... 300 多个直接用字面量并集的成员 | pay-log;改动后export interface RouteMap { home: { path: /; params: Recordstring, never; }; user-center: { path: /user-center; params: { tab?: profile | security }; }; user-detail: { path: /user/:id; params: { id: string }; }; // ... 同样是 300 多组配置但每个路由的信息集中在一个接口里 } export type RouteName keyof RouteMap;keyof RouteMap生成的结果从类型角度看依然是一个联合类型成员还是那几百个home | user-center | ...。但关键在于这个联合类型是从一个已知接口的键集合里提取出来的编译器在处理keyof时会使用内部对对象键的整块处理不需要像手写字面量联合那样每一个成员都当作独立类型节点反复保存和比较。更重要的收益在后面RouteParamsK这类工具类型可以直接通过索引访问完成绕开条件链。3.2 策略二用索引访问代替条件类型展开按需取类型这是整个优化里最核心的一刀。原来RouteParamsK是一条冗长的条件链export type RouteParamsK extends RouteName K extends user-detail ? { id: string } : K extends role-detail ? { id: number; tab?: overview | members } // ... 其余几百行 : Recordstring, never;改成索引访问之后变成export type RouteParamsK extends RouteName RouteMap[K][params];一行搞定。原理很简单RouteMap[K]是按需查找编译器只去接口里拿K对应的那一项。而原来的写法是逐个比较先把K和第一个字面量比不行再和第二个比不行再和第三个比……一直试到命中为止。命中前所有的失败判断都是白烧的 CPU。这个模式不只能处理路由参数凡是从一个名字推导配置的场景都适用权限码、图表类型、表单字段配置全部是同一套思路。核心原则就是让编译器直接通过索引跳过去而不是遍历整张表做匹配。提示优先用RouteMap[K]这种索引访问而不是打开RouteMap后把所有成员联合起来再次遍历。前者是取某一行后者是把表读一遍再自己筛。3.3 策略三给条件类型套上防分布“夹板”有些场景确实绕不开条件类型比如判断某个泛型是不是never、是不是string。这种情况下我们要做的是阻止 TypeScript 的默认分布行为。先看一个常见写法type IsStringT T extends string ? true : false; // 调用时T 如果是联合类型 type A IsStringa | 1; // boolean: true | false如果业务本意是这个整体联合类型是不是可赋值给 string那可能连逻辑都是错的。正确又高效的写法是把泛型参数包进元组强迫编译器把它当成一个整体来比较type IsStringT [T] extends [string] ? true : false; type B IsStringa | 1; // false因为它中间隔了 1方括号[T]在这里就是夹板它让T不再保持裸类型参数的身份TypeScript 就不会把它拆开做分布。很多业务里出现的高成本条件类型本身根本不需要分布语义单纯是因为没包夹板才白白承担了数倍的展开成本。所以优化的原则也很简单除非你明确要逐个处理联合成员否则一律给条件类型套上[T]。3.4 策略四控制巨型联合类型的文件边界减少跨模块污染类型重构做完之后我还发现了一个容易被忽略的问题项目里很多模块会import * as RouteTypes from /types/route然后整个模块到处引用这些巨型联合。这种批量 re-export 会让巨型联合类型被带进每一个 import 它的文件的 program 里等于人人家门口都摆了一张大名单。解决方式有两层。第一层是代码层面尽量做到按需 import type避免用import * as AllTypes这种把所有类型一次拉进来的写法。第二层是文件组织层面把重型映射结构单独放一个文件其他需要消费特殊类型的地方再从这个小文件里取而不是从一个大而全的types/index.ts拿。这一层改动没有直接减少类型实例化次数但它砍掉了很多类型被无意引用导致提前实例化的场景。我在实际项目里做下来跨文件引用收窄之后Watcher 模式下的增量编译时间又往下走了 0.3 秒左右。4. 一次完整修复复盘诊断数据、改动记录与结果对比4.1 优化前基线一套 320 组路由配置的真实数据为了让大家对整个优化路线有个量化感知我贴一份当时项目里的真实诊断数据。项目规模是 512 个 TypeScript 文件路由表 320 多组路由相关类型散落在三个文件里还叠加了权限、菜单、表单配置的联合类型。优化前的tsc --noEmit --extendedDiagnostics关键输出Files: 512 Types: 38,472 Instantiations: 4,712,351 Memory used: 2,067,890K Assignability cache size: 4,102,331 Time: 8.2sIDE 观测结果更直观悬停路由相关类型时状态栏经常转圈 2~3 秒每次 CtrlS 保存文件右下角 TypeScript 的检查图标要转好几圈才停下来自动补全偶发空白。4.2 三次改动的耗时变化记录修复过程我拆了三步每步都用tsc --extendedDiagnostics重新量了一次。第一次改动把 320 组路由从手写联合 条件链改成RouteMap映射结构 keyof。Files: 512 Types: 28,103 Instantiations: 2,890,412 Memory used: 1,294,231K Assignability cache size: 2,102,338 Time: 5.1s效果立竿见影。Instantiations 从 470 万降到 289 万编译时间从 8.2 秒降到 5.1 秒。这一步解决的是每个路由参数都要靠条件链逐个碰撞的核心开销。第二次改动把路由参数相关工具类型全部从条件类型替换成RouteMap[K][params]索引访问同时给业务代码里残留的裸条件类型套上[T]夹板。Files: 512 Types: 24,006 Instantiations: 1,561,208 Memory used: 798,134K Assignability cache size: 1,102,871 Time: 2.8s这一步把编译时间又砍掉了近一半。原因很单纯索引访问不产生条件分支也不会因为联合成员增多而额外展开。第三次改动收窄跨文件引用把重型类型从公共 index barrel 文件里独立出来同时给确实还要用联合类型的地方装上显式类型别名。Files: 512 Types: 22,401 Instantiations: 1,044,202 Memory used: 654,100K Assignability cache size: 980,221 Time: 1.6s三次改动累计效果编译时间从 8.2 秒降到 1.6 秒类型实例化次数从 471 万降到 104 万内存占用从 2GB 降到 640MB 左右增量编译从 3 秒降到 0.5 秒内。4.3 验证 IDE 体感悬停提示与增量编译的改善数据和体感是能对上的。优化后VS Code 里鼠标悬停几乎不再转圈返回提示的速度肉眼可见地变快。保存文件后的增量检查图标基本一闪而过。原来打一个点号要停顿半天才出补全的字段列表现在能和普通项目一样即时弹出。我还专门验证了一个很容易误判的点改名和跳转定义。之前改一个路由字符串IDE 要卡两三秒才能把所有引用位置列出来优化后基本秒出。这说明类型引用之间的关联计算少了编辑器在处理跨文件依赖时也轻快了很多。5. 防止下次再爆联合类型的体量红线与自动化体检5.1 给联合类型定“体量红线”多少成员就该警惕优化做完之后最怕的就是过了两个月又有人为了省事把几百个成员堆回一个联合类型里。所以我在团队里定了几条很直观的红线这些值来自实际压测大家可以参考。单个手写字面量联合类型的成员超过 50 个时就要考虑改成映射结构或枚举式接口。条件类型链条超过 5 层且入参是联合类型时必须重构成索引访问。任何裸类型参数的条件类型默认都要用[T]包一层除非有明确的分布需求。判断标准不完全看成员数绝对值而是看这个联合类型有没有被多个工具类型继续消费。如果它只是单纯被赋值、被提取值50 个成员问题不大一旦它进入条件类型、泛型约束、交叉运算这些高消耗场景就要提前拆解。5.2 把类型诊断接入 CI让性能问题随代码提交暴露靠自律撑不过三个月。更靠谱的做法是把类型性能指标写进 CI 的检查脚本里让它在每次合并前自动跑一遍。最简单的方案是在 CI 里加上npx tsc --noEmit --extendedDiagnostics然后用脚本从输出里提取Instantiations和Memory used两个字段跟基线做对比。比如规定Instantiations 超过 120 万就警告超过 200 万直接阻断合并。这样一旦有新代码引入巨型联合类型性能问题会在 code review 阶段就暴露出来而不是等上线后大家一起去体会编辑器卡顿。如果团队想看得更细还可以把--generateTrace产出给收集起来配合 analyze-trace 定期导出一份各文件耗时报告。哪个文件类型复杂度明显上涨一眼就能看出来。5.3 对外部依赖的巨型联合类型做隔离而非常量使用还有一种更难处理的场景巨型联合类型是第三方库里导出的比如自动生成的 SDK 类型、某个包里的请求参数类型。这些我们没法直接改源码但可以在业务代码里做隔离。具体做法是在项目里定义一个薄薄的本地类型层把第三方巨型类型按字段拆出几个小联合而不是在 30 个文件里直接 import 并消费原始类型。举个例子第三方库可能导出了这样一个类型import type { SDKEventMap } from some-large-sdk; // 在项目里定义一个更窄的本地映射 export interface ProjectEventMap { login: SDKEventMap[login]; logout: SDKEventMap[logout]; // 只用用到的几个不把整个联合导进来 } export type ProjectEventName keyof ProjectEventMap;这样一来业务代码只依赖本地的 5 个事件名不会因为 import 了第三方巨型类型让它在整个项目里到处参与类型运算。这个方法对封装 SDK、适配老项目很有用算是不改变底层类型只改变暴露面的思路。最后再说一个贯穿始终的小技巧所有优化动完手第一时间把tsc --extendedDiagnostics的输出留档再配合--incremental跑一次 watch。升级 TypeScript 版本也别忘了重新测一遍基线5.0 之后编译器对联合类型的缓存能力提升明显有时候升级一下就能白捡 10%~20% 的提速。这个领域没有银弹但把类型建模从比大小变成查字典卡顿问题基本能解决大半。