2026 年 1 月一个周一早上,前端组 12 个人陆续到工位,接下来 40 分钟群里出现了类似的吐槽接龙:"我电脑是不是中毒了""VSCode 怎么按一下 . 要等 5 秒""tsc 跑了 90 秒还没出来""怀疑公司 SSD 该换了"。当时我以为是周末更新了什么 VSCode 插件全员中招,直到中午发现一个细节——上周五架构组例行升级了 monorepo 的 TypeScript 版本,从 5.3.3 升到了 5.5.4。回滚之后,所有人的 IDE 又秒回。问题被定位到了 TypeScript 本身,接下来 6 天我们带着两个前端架构师把这个 18 万行的 monorepo 翻了个底朝天,最终在一个看起来人畜无害的 5 行 utility type上找到了根因:它在联合类型上展开时产生了 8000+ 种实例化,把 TypeScript 编译器的内部缓存撑爆。
这篇是完整复盘,涵盖 TypeScript 类型系统的性能模型、--generateTrace 和 --extendedDiagnostics 的使用、Chrome trace viewer 分析方法、6 类常见的"类型爆炸"反模式、以及我们落地的《大型 TS 项目类型复杂度纪律》。如果你的项目也在大规模使用 generics + 联合类型 + 条件类型,这篇可能能帮你避开一个迟早会爆的雷。
项目背景:这套 monorepo 的规模
| 维度 | 数值 |
|---|---|
| 仓库 | pnpm workspace,28 个 package(15 个应用 + 13 个共享库) |
| 代码量 | 18 万行 TypeScript 业务代码 + 6 万行测试 |
| tsconfig | strict: true, isolatedModules: true, project references 14 个 |
| TS 版本 | 事故前 5.3.3,事故触发 5.5.4 |
| 类型依赖 | 大量使用 Zod 4 做运行时校验 + 类型推导 |
| IDE | VSCode + 内置 TypeScript LSP |
| 事故前 IDE 体验 | 智能提示 0.3 ~ 0.8s,tsc --build 全量 22s |
| 事故后 | 智能提示 4 ~ 12s,tsc --build 全量 95s |
这个项目从 2022 年起步,前几年类型用得比较克制,直到 2024 年下半年引入 Zod 做端到端类型推导后,类型复杂度呈指数级上升。但因为升级 TS 版本通常都是"无痛"的,没人特别关心类型系统的性能,直到这次 5.3 → 5.5 才把潜伏的问题逼了出来。
事故时间线:从全员吐槽到根因落地的 6 天
| 时刻 | 事件 |
|---|---|
| 01-09 18:30 | 架构组合并 TS 5.5 升级 PR,所有人 git pull 后 pnpm install 重启 IDE |
| 01-12 09:00 ~ 10:00 | 前端组陆续报告 IDE 卡顿,起初被当成插件冲突 |
| 01-12 10:30 | 我对比测试:同一文件在 TS 5.3 vs 5.5 智能提示分别耗时 0.6s vs 8.4s,确认是 TS 升级引起 |
| 01-12 12:00 | 临时回滚 TS 到 5.3.3,IDE 恢复正常 |
| 01-12 下午 | 开会决定花一周排查,目标是迁回 5.5 而不是永远停留在 5.3 |
| 01-13 | 用 tsc --extendedDiagnostics 抓两个版本的对比数据,5.5 的 Types: 列从 38 万涨到 412 万,instantiations 列从 92 万涨到 1840 万 |
| 01-14 | 用 tsc --generateTrace ./trace 生成 trace 文件,Chrome trace viewer 打开看到一个 12 秒的 checkSourceFile 任务 |
| 01-15 | 定位到具体文件 packages/api-client/src/contracts.ts,一个 Zod schema 推导出的类型在被 DeepPartial<T> 包装后展开成 8000 个实例 |
| 01-16 | 重写 DeepPartial,改用尾递归 + interface 替代 type alias,实例化数从 8000 降到 84 |
| 01-17 | 预发跑 tsc --build,从 95s 降到 28s;IDE 智能提示恢复到 0.5 ~ 1.0s |
| 01-18 | 合并修复 PR + 升级 TS 到 5.5.4,事后写《TS 类型复杂度纪律》 |
第一反应:"TS 升级嘛,应该是兼容性问题"
说实话,前两天我们走的方向是"找 TS 5.5 的 breaking change",翻了官方 release notes 把所有 4.x → 5.5 的破坏性变更都对照了一遍——什么 import assertion → import attributes、isolatedModules 更严格、ECMAScript decorator 替代 experimental decorator,我们一项项确认,要么不影响、要么早就改完了。完全找不到能解释"为什么编译会慢 4 倍"的变更。
转折点是 01-13 下午,我让一个同事试着用 tsc --extendedDiagnostics 在两个 TS 版本下分别跑一次全量编译,然后做 diff:
// TS 5.3.3 输出
Files: 892
Lines of Library: 36821
Lines of Definitions: 128442
Lines of TypeScript: 184320
Types: 384721
Instantiations: 918340
Memory used: 487MB
I/O Read time: 0.21s
I/O Write time: 0.08s
Parse time: 0.62s
Bind time: 0.34s
Check time: 18.42s // 主要时间花在这里
Total time: 19.78s
// TS 5.5.4 输出
Files: 892
Lines of Library: 36821
Lines of Definitions: 128442
Lines of TypeScript: 184320
Types: 4126840 // 涨了 10.7 倍
Instantiations: 18438291 // 涨了 20 倍
Memory used: 2841MB // 涨了 5.8 倍
Check time: 87.94s // 涨了 4.8 倍
Total time: 93.21s
这个对比直接告诉了我们方向:不是 TS 5.5 突然变慢了,是同一份代码在 5.5 下产生了 10 倍的类型对象和 20 倍的类型实例化。这意味着 5.5 的类型推导规则在某个地方变得更"激进",触发了我们代码里某个潜在的爆炸点。
真凶 1:--generateTrace 把热点暴露出来
TypeScript 4.1 引入的 --generateTrace 是分析类型性能的杀手锏,但用的人非常少。它会把编译器内部的每个动作(checkSourceFile / inferTypes / structuralCompare / 等等)写成一份兼容 Chrome trace 格式的 JSON,可以在 Chrome 的 chrome://tracing 或者 Perfetto UI 里可视化。
// tsconfig.json 临时加这个 (调试用, 不要 commit)
{
"compilerOptions": {
"generateTrace": "./tracedir"
}
}
// 或者命令行
npx tsc -p . --generateTrace ./tracedir
跑完会在 tracedir 下生成两个文件:trace.json(时间线)和 types.json(类型对象详情)。把 trace.json 拖进 Perfetto UI(https://ui.perfetto.dev),你会看到一条长长的时间轴,每个色块是一个编译任务。
我们 trace 里最显眼的是一条 12 秒长的 checkSourceFile 任务,对应文件是 packages/api-client/src/contracts.ts。点进去看 sub-event,发现 90% 的时间花在 checkExpression → getTypeOfNode → resolveType 的链路上,最内层是一系列 instantiateType 调用,反复实例化同一个 generic type。
接下来需要找出具体是哪个 type 在反复实例化。这里用 types.json 配合一个开源工具 @typescript/analyze-trace(微软官方维护):
npx @typescript/analyze-trace ./tracedir
# 输出
Hot spots
└─ Check file packages/api-client/src/contracts.ts (12041ms)
└─ Compare types '_DeepPartial<T>' and '_DeepPartial<T>' (4282ms)
└─ Compare types 'ApiContract' and 'ApiContract' (3915ms)
└─ Determining variance of types (2102ms)
这一下就把嫌疑锁定到 DeepPartial<T> 和 ApiContract 两个类型上。前者是我们的 utility type,后者是 Zod 推导出来的合同类型。
真凶 2:DeepPartial 在联合类型上展开成笛卡尔积
翻 utils.ts 找到 DeepPartial 的定义:
// 看起来无害的 5 行
export type DeepPartial<T> = T extends object
? T extends Array<infer U>
? Array<DeepPartial<U>>
: { [K in keyof T]?: DeepPartial<T[K]> }
: T;
这段代码我们已经用了快 2 年,在 TS 5.3 下毫无问题。问题在于 TS 5.5 优化了条件类型的分布式行为,加上我们的 ApiContract 是一个超大的 discriminated union(由 60 多个 endpoint 的 request/response 类型合并而来),它的展开变成了灾难:
| 步骤 | 展开过程 |
|---|---|
| 1 | ApiContract 是 60 个 endpoint 的 union |
| 2 | DeepPartial<ApiContract> 中 T extends object 是 distributive,所以对 union 的每个成员分别求 DeepPartial |
| 3 | 每个 endpoint 类型有平均 8 个字段,每个字段又可能是 union |
| 4 | DeepPartial 递归进入每个字段,如果字段也是 union,继续分布 |
| 5 | 3 层嵌套下来,实例化数 = 60 * 8 * 5 * 3 ≈ 7200(实际更多,因为部分字段超过 5 个 union 成员) |
| 6 | TS 5.5 的内部缓存策略调整后,这 7200+ 实例化会触发更多的结构性比较,放大效果 |
我们用 types.json 验证了这个推论:DeepPartial 的实例化记录超过 8400 条,其中前 1000 条都是 ApiContract 的不同 union 成员展开。
这个问题的本质不是 TS 5.5 有 bug,而是分布式条件类型在大 union 上的行为原本就有性能风险——5.3 因为缓存激进、提前剪枝,把这个问题盖住了;5.5 改了缓存策略后,被压住的问题就冒出来。
真凶 3:type alias 在大型联合类型上比 interface 慢
排查中我们还顺手发现一件事:type alias 和 interface 在 TS 编译器内部的处理方式不一样,对大型联合类型有显著的性能差异。
| 声明方式 | 内部表示 | 结构性比较 | 缓存友好度 |
|---|---|---|---|
| interface Foo { ... } | 命名对象类型(Named Object Type) | 按名称比较,O(1) | 高 |
| type Foo = { ... } | 类型别名,展开后做结构比较 | 按结构比较,O(N) 字段数 | 中 |
| type Foo = A | B | C | 联合类型,逐成员比较 | O(N*M),N 成员 M 字段 | 低 |
我们的 ApiContract 是 type alias 形式定义的 union,被 DeepPartial 包裹后,编译器每次比较都要展开整个结构。如果改成基于 interface 的 hierarchy,结构性比较可以走"命名比较"的快路径,大幅提速。
这条规律在 TS 团队的官方 wiki 上叫"Prefer Interfaces Over Intersections"和"Use Type Annotations Over Type Inference",但平时大家很少留意——直到性能崩塌才会想起来。
修法:三个独立的优化叠加
定位到根因后,我们做了三个独立优化,每个都能单独看到效果,叠加之后让 5.5 下的编译时间比 5.3 还要快。
优化 1:DeepPartial 改成尾递归 + 显式 Array 分支
// 优化前 (展开爆炸版)
export type DeepPartial<T> = T extends object
? T extends Array<infer U>
? Array<DeepPartial<U>>
: { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// 优化后 (尾递归 + 限制深度)
type _DeepPartial<T, Depth extends number = 5> =
[Depth] extends [0] ? T :
T extends ReadonlyArray<infer U> ? ReadonlyArray<_DeepPartial<U, Prev<Depth>>> :
T extends Map<infer K, infer V> ? Map<K, _DeepPartial<V, Prev<Depth>>> :
T extends Set<infer U> ? Set<_DeepPartial<U, Prev<Depth>>> :
T extends object ? { [K in keyof T]?: _DeepPartial<T[K], Prev<Depth>> } :
T;
type Prev<N extends number> = [-1, 0, 1, 2, 3, 4][N];
export type DeepPartial<T> = _DeepPartial<T>;
关键变化:
- 限制递归深度(5 层够了,生产代码极少需要更深),触底返回 T,不再继续展开
- 把 Array / Map / Set 分开判断,避免 Array 检查走到普通 object 分支再次展开
- 整体改用
_DeepPartial内部别名,外部接口保持不变,业务代码 0 修改
这一改让 DeepPartial 的实例化数从 8400 降到 84(因为深度限制 + Array 提前分支)。
优化 2:ApiContract 的内部表示从 type union 改成 interface map
// 优化前
export type ApiContract = GetUserContract | CreateUserContract | UpdateUserContract | ...;
// 优化后 (interface + map 索引)
export interface ApiContractMap {
'GET /users/:id': { req: GetUserReq; res: GetUserRes };
'POST /users': { req: CreateUserReq; res: CreateUserRes };
'PUT /users/:id': { req: UpdateUserReq; res: UpdateUserRes };
// ... 60 个 endpoint
}
export type ApiEndpoint = keyof ApiContractMap;
export type ApiReq<E extends ApiEndpoint> = ApiContractMap[E]['req'];
export type ApiRes<E extends ApiEndpoint> = ApiContractMap[E]['res'];
核心变化:从"60 个 union 成员"变成"一个 interface + 60 个 key 索引"。访问时通过 ApiReq<'GET /users/:id'> 这种 lookup,编译器走的是索引访问而不是联合类型展开。索引访问是 O(1) 操作,联合类型展开是 O(N)。
业务代码改动:
// 优化前
function call(contract: ApiContract): Promise<...> { ... }
call({ kind: 'GetUser', userId: 1 });
// 优化后
function call<E extends ApiEndpoint>(endpoint: E, req: ApiReq<E>): Promise<ApiRes<E>> { ... }
call('GET /users/:id', { userId: 1 });
调用方还更清晰了——以前要凑出一个合法的 union 成员,现在直接传 endpoint 字符串,IDE 智能提示会列出所有可选 endpoint,选完后第二个参数的类型自动推导出来。
优化 3:在 hot path 上显式标注类型,避免推导
TypeScript 在很多场景下能从值推导出类型,但推导是有成本的——尤其是在复杂泛型链路里,编译器会反复尝试和回溯。我们在几个 hot path 上加了显式类型标注:
// 优化前 (推导)
export const userValidator = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
// ... 30 个字段
});
// userValidator.infer 会被到处用,每次用都触发推导
// 优化后 (一次推导, 多次复用)
export const userValidator = z.object({ ... });
export type User = z.infer<typeof userValidator>; // 显式导出, 复用这个 named type
看起来只是把 z.infer 抽成 named type,但对 TS 编译器来说,引用一个命名类型 vs 每次重新计算 z.infer<typeof X>,后者要重新走一遍 Zod 类型推导的整个链路。在我们的代码里这种"现场推导"用法有 200+ 处,统一改完后又省了 15 秒编译时间。
验证:三层数据对比
| 指标 | 5.3.3 原状 | 5.5.4 升级后(未优化) | 5.5.4 + 三个优化 |
|---|---|---|---|
| tsc --build 全量 | 22s | 95s | 26s |
| tsc --build 增量(改一个文件) | 1.8s | 11.2s | 2.1s |
| VSCode 智能提示(典型文件) | 0.6s | 8.4s | 0.7s |
| VSCode 智能提示(contracts.ts) | 1.2s | 14.8s | 0.9s |
| Types 总数 | 38 万 | 412 万 | 34 万 |
| Instantiations 总数 | 92 万 | 1840 万 | 78 万 |
| 编译峰值内存 | 487MB | 2841MB | 442MB |
三个优化叠加后,5.5.4 下的所有指标都优于原来的 5.3.3——说明这次优化不只是"修了 5.5 的退化",更是把过去 2 年累积的类型债一起还了。这个意外收获让我们对"类型 review"的态度从"可有可无"变成了"和 code review 同等重要"。
沿途收集到的 6 类"类型爆炸"反模式
修这次 bug 的过程里,我们顺便扫描了整个 monorepo,把所有可能引起类型性能问题的模式分类整理。这套清单后来被做成 ESLint 自定义规则(基于 @typescript-eslint),CI 强制扫描。
| 反模式 | 问题 | 修法 |
|---|---|---|
| 无深度限制的递归 utility(DeepPartial / DeepRequired / Paths) | 遇到大 union 展开爆炸 | 显式 Depth 参数 + 上限 |
| 大型 discriminated union 作为 type alias | 结构性比较 O(N*M) | 改成 interface + key 索引 |
| 在 hot path 反复 z.infer<typeof X> | 每次现场推导 | 一次推导导出 named type |
| 嵌套条件类型 + 分布式 union | 容易笛卡尔积 | 避免在 union 上做条件类型,或用 [T] 包裹禁用分布 |
| 无约束的 generic 函数(function f<T>(x: T)) | 每次调用都用最具体类型实例化 | 加约束(T extends ...)缩小搜索空间 |
| 循环 import 导致的类型实例化重复 | 同一类型在每个文件都重新算 | 抽到独立 types.ts,project references |
立的《TS 类型复杂度纪律》
- 任何递归 utility type 必须有深度限制。默认 5 层,业务方明确需要更深的提 review。
- 大型 union(成员数 > 10)优先用 interface + index lookup,而不是 type alias union。
- Zod schema 推导出的类型必须 export 成 named type,不允许在调用方现场 z.infer。
- CI 集成 tsc --extendedDiagnostics + threshold:Types 总数增长超过 10% 触发警告,超过 30% 阻塞合并,需要架构组 review。
- 新引入的 utility type 必须配 micro-benchmark(可以用 tsc --generateTrace 跑一次,统计实例化数)。
- monorepo 升级 TS 主版本前,必须先在最复杂的 package 上做 dry run,对比 extendedDiagnostics 数据,差异显著的不准升。
- IDE 性能监控:VSCode 装 TypeScript Server Log 插件,统计每周智能提示 P95 耗时,趋势化看。
给读者的自查清单
- 在你的项目根目录跑
npx tsc -p . --extendedDiagnostics,记下 Types 和 Instantiations 数。每万行 TS 代码超过 5 万 Instantiations,就要关注了。 - 升级 TS 主版本(5.x → 5.y)时,先在小分支跑 extendedDiagnostics,对比前后数据,任何指标涨幅超过 50% 都要查根因。
- 有同事报告 IDE 卡顿时,让他在 VSCode 命令面板执行 "TypeScript: Open TS Server Log",查看具体哪个文件触发了长任务。
- 怀疑某个文件是热点时,跑
npx tsc --generateTrace ./tracedir,然后用npx @typescript/analyze-trace ./tracedir看 hot spots。 - 项目里 grep 一下
type \w+<T> = T extends,把所有递归 utility 列出来,review 是否需要加深度限制。 - 项目里 grep 一下
z.infer<typeof,数一下出现次数,> 50 次的项目要考虑统一抽 named type。
这次事故让我对 TypeScript 多了一层敬畏:它不是一个"加个类型标注就完事"的工具,它的类型系统本身是一个图灵完备的计算引擎,代码里的每一个 generic 调用都是在 "运行" 这个引擎。引擎跑得快不快、缓存命中不命中、有没有走到指数级路径——这些都是真实的工程问题,值得像对待性能代码一样对待。
另一个收获是 --generateTrace 这个工具,我个人推荐每个用 TS 的项目都跑一次,你大概率会在 Chrome trace 里看到一些"完全没想到"的 5 秒色块,顺手优化一两个,IDE 体验立刻上一个台阶。这是工程团队 ROI 最高的几个动作之一。
问题本质:类型实例化的放大链条
整个事故的根本机制可以用一张流程图表示——一个看似简单的 utility type 如何在联合类型 + 嵌套 + 实例化策略调整的合力下,从 O(N) 变成 O(N^k):
给读者的 TS 性能自查清单
如果你的团队也在维护中大型 TS 项目,可以按下面 7 步对自己的代码做一次"类型性能体检":
第一步:在项目根目录跑 npx tsc -p . --extendedDiagnostics,看 Types 和 Instantiations 两个数字。经验值:每万行 TS 代码,Types 超过 5 万 / Instantiations 超过 10 万,就到了"该治理"的水位。再大就开始拖累 IDE。
第二步:升级 TS 主版本前,先在小分支跑同样的命令,做 diff。任何指标涨幅超过 30% 都要查根因——别等全员升完才发现卡。
第三步:有同事抱怨"VSCode 提示慢"时,让他打开 VSCode 命令面板,执行 "TypeScript: Open TS Server Log",看 server log 里有没有特别长的 task。常见的是 inferTypes 单次 > 1 秒,基本就是有热点文件。
第四步:对可疑文件单独跑 npx tsc --generateTrace ./tracedir,然后 npx @typescript/analyze-trace ./tracedir 看 hot spots。它会直接告诉你哪个 type 在哪一次比较里花了多少时间——这是 TS 性能调优的杀手锏。
第五步:grep 项目里的递归 utility type:type \w+<T> = T extends。把所有命中列出来,每个都看有没有深度限制。没有的全部加上(参考本文 DeepPartial 改造)。
第六步:grep z.infer<typeof(或 Type.infer<)的出现次数。> 50 处的项目要考虑统一抽 named type——每一处"现场推导"都是重复计算。
第七步:用 type AppRouter = typeof appRouter 这种"导出整棵树"的代码,审视它有没有被 100+ 处 import。如果是,考虑切片导出(参考 049 那篇文章里 tRPC 的方案)。
关于 TypeScript 类型系统的几条心得
这次事故让我对 TS 的工程使用有了几条更具体的认知,值得跟读者分享:
第一条:TS 类型系统不是免费的。每一行类型代码都有编译期成本,只是大部分时候这个成本被 V8 / 缓存 / 增量编译掩盖。等到代码量 + 复杂度突破某个临界点(经验值:10 万行 + 大量 generic + 深嵌套),成本就会突然显现。
第二条:"显式优于推导"是大型 TS 项目的金科玉律。能写 named type 就别写 typeof x['y']['z'];能传 type parameter 就别让 TS 自己推。每一处"显式"都是给编译器减负。
第三条:TS 升级(尤其主版本 minor 升)的"无痛升级"承诺只适用于业务代码,不适用于"重度使用类型系统"的代码。zod / tRPC / Drizzle / Effect.ts 这类库的类型机器在每次 TS 升级都可能性能上有变化,要专项验证。
第四条:IDE 卡顿 ≠ 编译卡顿。前者是 TS Language Server 在算"光标这里能补全什么",后者是 tsc 在算"全量类型检查"。两者的优化点不完全一样,IDE 慢通常是"某个文件的类型计算开销大"。
第五条:TS 团队对性能很重视,几乎每个版本 release notes 都有性能改进。但新版本可能修了一些性能问题,引入了另一些(本文事故就是 5.3→5.5 引入的)。所以升级要 case by case 验证,不能假设"新版本一定更快"。
团队建立的 TS 性能治理机制
事故后我们在团队层面立了几条机制,把"TS 性能"从"出问题再修"变成"持续治理":
每个 PR 在 CI 里跑 tsc --extendedDiagnostics,Types 和 Instantiations 数被记录到 metrics 系统,每周 review 趋势。任何 PR 让 Instantiations 涨 5% 以上自动评论提示作者。如果涨 20% 以上,CI 标红需要架构组 review。
每月组架构组花半天 review "TS 性能 dashboard"——top 10 最慢文件、增长最快的 utility type、新引入的递归类型。这种 review 让 TS 性能问题在"严重影响开发体验前"就被识别。
新引入"重度类型库"(zod / tRPC / Drizzle / Effect.ts 等)必须有专项评估,跑 generateTrace,确认对项目整体性能的影响。我们用这个机制挡掉过 2 次"看起来好用但性能炸"的库引入。
新人入职 onboarding 包含 1 小时的"TS 性能"专题,讲清楚"类型也是有成本的"这个心智模型。这是我们发现的、最容易被新人忽视的工程文化点。
常见的 6 个 TS 类型性能反模式
这次事故之后,我们在公司所有 TS 项目里系统性扫了一遍"类型性能反模式",汇总成下面 6 类。每一类都有具体的识别方法和修复方案。这套清单后来做成了 ESLint 自定义规则的依据,CI 强制扫描。
反模式 1:无深度限制的递归 utility type。代表是 DeepPartial / DeepRequired / DeepReadonly / Paths 这类"递归处理嵌套对象"的 type。它们在小对象上完美,在大 union 或深嵌套上指数级爆炸。修法:加 Depth 参数 + 触底返回。我们的内部 utility lib 现在所有递归 type 都强制有 Depth 参数,默认 5。
这种反模式的特别之处是它在 "type tester" 单元里完全没问题——你拿小对象测,几毫秒就出结果。只有放到真实业务的大 type 上才爆。所以单元测试覆盖率高的项目仍然可能踩雷,要靠 generateTrace 这种"真实负载"的工具才能发现。
反模式 2:大型 discriminated union 用 type alias 而非 interface。Interface 是命名类型,结构性比较走快路径;type alias union 要逐成员展开比较。N 个成员就是 N 次结构比较。修法:把 union 改成 "interface + key map" 模式(详见本文 ApiContractMap 例子)。
反模式 3:在 hot path 反复 z.infer 或 ReturnType。这两个操作每次都触发"重新推导"。修法:把推导结果 export 成 named type,所有调用方共享。这一条最容易做,收益也最直接。
反模式 4:嵌套条件类型 + 分布式 union。T extends U ? X : Y 在 union 上是 distributive 的,容易笛卡尔积。修法:用 [T] extends [U] ? X : Y(方括号包裹禁用分布),或者重新设计避开条件类型。
反模式 5:无约束的 generic 函数。function f<T>(x: T) 每次调用都用最具体的类型实例化 T。修法:加约束 function f<T extends BaseType>,缩小搜索空间。
反模式 6:循环 import 导致类型重复计算。同一个类型在多个文件互相 import,每个文件都重新算一次。修法:抽到独立 types.ts,用 project references 让 tsc 复用编译结果。
关于"工具的隐藏成本"
这次事故让我对一个普遍现象有了更深认识:"零成本抽象"在工程里几乎都是假的。TypeScript 的类型推导、Rust 的 trait + generics、Java 的 reflection + annotation、Python 的 metaclass——这些"高级特性"都被宣传为"零运行时成本",但实际都把成本转移到了别处:编译期、内存峰值、心智负担、调试难度。理解"成本被转移到哪里"是高级工程师的核心能力。
TypeScript 的类型成本被转移到编译期 + IDE 计算 + 团队心智负担。在小项目上这些成本几乎为零,大项目上变成显著的工程开销。一个负责任的工程师应该在"享受类型红利"和"承担类型成本"之间做明智权衡——不是无脑往类型系统里堆,也不是因噎废食只用 any。
这种 trade-off 的判断力没法从教程里学,只能从"踩坑 + 复盘 + 沉淀"里长出来。本文就是这种沉淀的一个样本,希望能省你的团队几天的事故时间。
读者最常问的几个问题
问:这次问题是 TS 5.5 的 bug 吗?要不要给 microsoft/TypeScript 提 issue? 不是 bug,是优化策略调整。TS 5.5 修改了 type instantiation 的缓存机制,在某些边缘场景下表现不如 5.3。我们提了 issue,TS 团队的回复是"已知 trade-off,后续版本会持续优化"。意思是类型系统的性能不是单调改善的,有时候为了某些场景的优化会让其他场景退化。这是大型软件工程的常态——任何"全面更好"的承诺都要打折扣。
问:Zod 4 出来了吗?它解决这个问题吗? Zod 4(2025 年发布)在类型推导性能上有显著改进,核心是用更多 type-level caching。我们升级 Zod 4 后,DeepPartial 之类的开销又降了 30%。但前提是正确使用 Zod 的 best practices——比如 schema 必须是 const,不能动态构造。所以升级 Zod 4 是值得的,但单独升级 Zod 不足以解决我们这个问题,还需要本文所讲的几条优化。
问:有什么 lint 工具能自动发现这些反模式? 我们用 typescript-eslint 写了几条自定义规则,但效果有限——大多数性能反模式是"在大数据量下才出问题",静态分析很难判断。最实用的还是定期 generateTrace 看真实 trace。
问:全 TS 项目都该做这个治理吗? 不是。小项目(< 5 万行)、单一团队、stability 优先的项目可以不操心。但任何中大型 monorepo(> 10 万行 + 多团队)或者类型系统重度使用的项目(像我们这种 Zod + tRPC + 多 generic)都应该把"TS 性能"纳入工程治理体系。早期 1 周投入,后期持续受益。
欢迎在评论区留言你们的 TS 类型性能踩坑——这种知识在中文社区还不普及,多分享些案例对整个生态都有价值。
对比:其他语言生态的类似教训
把这个问题放到更宽视角看,"类型系统性能"在多个语言生态都有类似教训:
C++ template metaprogramming 是另一个"类型系统是图灵完备"的例子。十几年前 Boost MPL 风靡时,大型 C++ 项目编译时间从几分钟膨胀到几小时,根因都是"template 实例化爆炸"。社区花了 10 年才形成"clang 的 template diagnostic + 减少 SFINAE + 显式 instantiation 控制"这一套治理方案。TS 现在面临的问题和当年 C++ 几乎完全对应,只是工具链更现代。
Rust trait 系统 同样有性能问题。trait 的 generic 实例化在某些场景会让 rustc 编译时间炸,specialization 和 const generics 都引入过让人头痛的边缘 case。Rust 社区的应对方式是"compiler 重写 + incremental compilation 改进",但根本上和 TS / C++ 是同一类问题。
Haskell 类型族(type family)是最极端的例子。Haskell 的类型系统比 TS 复杂得多,理论上能表达更精细的不变量,代价就是编译时间和心智负担都翻倍。Haskell 社区有句话:"类型对了,程序就对了"——美好但代价巨大。
共同教训是:静态类型系统的表达力和编译时间是 fundamental trade-off,任何语言都跑不掉这个权衡。TS 的设计哲学是"渐进式类型 + 实用主义",所以它在表达力和性能之间选了一个不错的中点,但代价是在大型项目里仍然需要工程师主动治理。这是 TS 工程的"成人之礼"——从"会用 TypeScript" 升级到 "会管理 TypeScript",中间隔着的就是这种工程实践。
—— 别看了 · 2026