2026 年 4 月,我们一个用 TypeScript 5.4 + Vite + Vue 3 写的低代码可视化编排平台(flow-studio),在产品迭代 18 个月后突然遇到一个大坑——"开发期类型推导慢到无法忍受,改一行类型代码,VSCode 转圈 15 秒,tsc --noEmit 完整跑一次 4 分 30 秒"。整个前端团队(11 人)平均每天损失 1.5 小时在等待 TypeScript。我们花了 2 周深度治理,把单文件类型检查从 15 秒压到 0.8 秒,tsc 全量从 4:30 压到 38 秒。
这次复盘是 TypeScript 性能治理的完整路径。从最初怀疑 IDE bug、内存不够,到最终发现真凶是"过度泛型 + 递归条件类型 + 巨型 union type 三重叠加"。这是 TypeScript 中大型项目最常见也最容易被忽视的性能陷阱。这篇文章给你一份"TS 性能优化清单 + 反模式检测脚本"。
项目背景:这个项目的 TS 规模
| 维度 | 规模/参数 |
|---|---|
| 代码量 | 8.7 万行 TS / TSX(含测试) |
| TS 版本 | 5.4.5 |
| tsconfig strict 等级 | full strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes |
| 类型导出文件数 | 1240 个 *.d.ts / *.types.ts |
| 核心 union type | NodeKind:142 种节点类型(条件、循环、API 调用…) |
| 开发期 VSCode 编辑延迟 | 15 秒 |
| tsc --noEmit 全量 | 4 分 30 秒 |
| 团队规模 | 11 人前端,日均 PR 18 个 |
| 每日总等待时间 | 16.5 小时(11 人 × 1.5 小时) |
项目从 2024 年中开始 TS 化,头 12 个月类型检查一直很快(< 30 秒),但从 18 个月开始急剧恶化。这种"慢到不能忍"的临界点突然来到,说明类型计算成本是非线性的——当某个阈值被突破后,性能断崖式下降。
事故时间线
| 时间 | 事件 |
|---|---|
| D1 | 团队 standup 集中抱怨"TS 卡到不能写代码",发起紧急优化 |
| D2 | 怀疑 VSCode 内存不足,改 8GB→16GB,VSCode 设置 maxTsServerMemory=8192,无改善 |
| D3-D4 | 用 tsc --extendedDiagnostics 和 --generateTrace 分析,发现 Type Instantiations 1.4 亿次 |
| D5-D7 | 用 @typescript/analyze-trace 定位 5 处类型反模式 |
| D8-D10 | 逐个重构,每改一处跑一次完整 tsc + tsserver 测试 |
| D11-D14 | 建立类型性能 CI 卡口 + 文档化反模式 |
第一轮:误以为是 IDE 问题
最容易想到的方向是"VSCode 不够用",我们做了几件事都没用:
# 1. 增加 VSCode 内存
# settings.json
{
"typescript.tsserver.maxTsServerMemory": 8192,
"typescript.tsserver.useSyntaxServer": "auto",
"typescript.tsserver.experimental.enableProjectDiagnostics": false
}
# 2. 试 tsserver 4.9 / 5.0 / 5.4 三个版本,都一样慢
# 3. 升级到 TypeScript 5.5 beta,反而更慢(5% 退化)
# 4. 关掉所有 VSCode 插件,只留 TS,无改善
这一轮让我们意识到:不是 IDE 慢,是 TypeScript 类型检查本身慢。tsc 在命令行跑也是 4:30,VSCode 只是个 IDE 包装。要解决就要从类型设计入手。
第二轮:用诊断工具看到真相
TypeScript 自带几个诊断标志,关键时刻是救命的:
# 1. 看类型检查耗时分布
npx tsc --noEmit --extendedDiagnostics
# 输出示例:
# Files: 1240
# Lines of Library: 39000
# Lines of TypeScript: 87000
# Identifiers: 280000
# Symbols: 1100000
# Types: 5800000 # 类型数,远超正常项目的 50万左右
# Instantiations: 142000000 # 类型实例化次数,1.4 亿!
# Memory used: 3200MB
# Total time: 270s
# 2. 生成 trace 文件
npx tsc --noEmit --generateTrace ./trace-output
# 生成 trace.json + types.json
# 3. 用 analyze-trace 找热点
npx @typescript/analyze-trace ./trace-output
看到 Instantiations 1.4 亿次 时我们就知道问题严重了——正常项目这个数字在 500 万-2000 万之间,我们超出 10-20 倍。这意味着 TypeScript 在反复计算同一些泛型实例,可以 cache 但没 cache,或者类型设计触发了组合爆炸。
analyze-trace 直接给出热点列表:
# analyze-trace 输出(精简)
Hot Spots:
1. checkExpression -> resolveCall
Location: src/core/node-builder.ts:142
Self time: 89 seconds (33%)
Reason: Recursive conditional type unfolding for NodeKind union
2. instantiateType
Location: src/types/flow-graph.ts:78
Self time: 56 seconds (21%)
Reason: Deep generic recursion in FlowGraph<T extends NodeKind>
3. getResolvedSignature
Location: src/api/typed-rpc.ts:23
Self time: 42 seconds (16%)
Reason: Distributive conditional type over 142-member union
问题本质:三种类型反模式叠加
反模式 1:142 成员的巨型 union type
// 反面教材:142 个成员的 union type
export type NodeKind =
| 'condition'
| 'loop'
| 'http-call'
| 'db-query'
| 'transform'
| 'parallel'
| 'serial'
| 'subprocess'
| 'error-handler'
// ... 共 142 个
// 任何用 NodeKind 做 distributive condition 的地方都会爆炸
export type NodeConfig<K extends NodeKind> =
K extends 'condition' ? ConditionConfig :
K extends 'loop' ? LoopConfig :
K extends 'http-call' ? HttpConfig :
K extends 'db-query' ? DbConfig :
// ... 142 个分支
never;
// 这个用法会让 TS 把 142 个分支全部展开,每次调用都计算一次
function buildNode<K extends NodeKind>(kind: K, config: NodeConfig<K>) {
// ...
}
当 NodeConfig<NodeKind> 这样用时,TS 会做"distributive conditional type"——把每个 union 成员展开成独立条件,142 个分支同时存在内存里。如果这个类型在 50 个地方被用,就是 142 × 50 = 7100 次实例化,叠加上嵌套泛型,很快就千万级。
反模式 2:递归条件类型不收敛
// 反面教材:深度递归条件类型,没有 base case 控制深度
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: T[K] extends Map<infer MK, infer MV>
? ReadonlyMap<DeepReadonly<MK>, DeepReadonly<MV>>
: T[K] extends Set<infer SV>
? ReadonlySet<DeepReadonly<SV>>
: DeepReadonly<T[K]>
: T[K];
};
// 这个递归没有显式深度限制,一旦遇到自引用类型就会爆炸
// 比如下面这个:
interface FlowGraph {
root: FlowNode;
metadata: Record<string, FlowGraph>; // 自引用!
}
type FrozenGraph = DeepReadonly<FlowGraph>; // TS 会无限递归直到 50 层深度限制
TypeScript 默认递归深度是 50 层,超过会报 "Type instantiation is excessively deep"。即使没报错,40+ 层递归在每次计算时都极慢。我们这个项目里有 4 处 DeepReadonly / DeepPartial 用在自引用类型上,每次类型检查都触发数千次深度递归。
反模式 3:不必要的泛型参数
// 反面教材:几乎所有函数都泛型化,但泛型并没解决类型问题
class FlowExecutor<
TNode extends NodeKind,
TInput extends NodeInput<TNode>,
TOutput extends NodeOutput<TNode>,
TContext extends ExecutionContext<TInput>,
TError extends ExecutionError<TNode>
> {
async execute(
node: NodeConfig<TNode>,
input: TInput,
ctx: TContext
): Promise<TOutput | TError> {
// ...
}
}
// 调用方:
const executor = new FlowExecutor<'http-call', HttpInput, HttpOutput, HttpContext, HttpError>();
// 问题:
// 1. 调用方要手写一长串泛型参数
// 2. TS 要为每个调用点单独实例化 5 个泛型参数的组合
// 3. 这些泛型其实可以从 TNode 推导出来,不需要显式
五种修法
修法 1:拆分巨型 union 为 discriminated union + 子集
// 正解:按业务分组拆分
export type FlowControlKind = 'condition' | 'loop' | 'parallel' | 'serial';
export type IOKind = 'http-call' | 'db-query' | 'message-queue' | 'file-io';
export type TransformKind = 'map' | 'filter' | 'reduce' | 'aggregate';
// ... 8 个分组,每组 10-25 个成员
// 顶层 union 是分组,不是 142 个成员
export type NodeKind = FlowControlKind | IOKind | TransformKind | ...;
// 用 discriminated union 而不是 NodeConfig<K> 映射
export type FlowControlNode =
| { kind: 'condition'; config: ConditionConfig }
| { kind: 'loop'; config: LoopConfig }
| { kind: 'parallel'; config: ParallelConfig }
| { kind: 'serial'; config: SerialConfig };
export type Node = FlowControlNode | IONode | TransformNode | ...;
// 处理时按分组 narrow
function processFlowControl(node: FlowControlNode) {
switch (node.kind) {
case 'condition': return handleCondition(node.config);
case 'loop': return handleLoop(node.config);
// ...
}
}
这一步把 Instantiations 从 1.4 亿降到 4200 万,tsc 时间从 4:30 降到 2:10。
修法 2:递归类型加深度限制
// 正解:显式深度限制,避免无限递归
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
type DeepReadonly<T, Depth extends number = 5> =
Depth extends 0
? T // 到达深度限制,停止递归
: T extends Function
? T
: T extends Array<infer U>
? ReadonlyArray<DeepReadonly<U, Prev[Depth]>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K], Prev[Depth]> }
: T;
// 自引用类型也能在 5 层后停止
type FrozenGraph = DeepReadonly<FlowGraph>; // 现在只会展开 5 层
这一步降 Instantiations 1500 万,tsc 时间到 1:30。
修法 3:移除不必要泛型,改用 conditional 推导
// 正解:只保留必要泛型,其他用 conditional 推导
class FlowExecutor<TNode extends NodeKind> {
async execute(
node: NodeConfig<TNode>,
input: NodeInput<TNode>, // 从 TNode 推导,不需要单独泛型
ctx: ExecutionContext // 通用 context,不泛型化
): Promise<NodeOutput<TNode> | ExecutionError> {
// ...
}
}
// 调用方变简洁,实例化也少
const executor = new FlowExecutor<'http-call'>();
这一步降 Instantiations 800 万,tsc 时间到 1:05。
修法 4:用 interface 替代 type alias 的复杂结构
// 反面教材:复杂 type alias,每次引用都重新计算
type ComplexState = {
user: UserState;
flow: FlowState;
ui: UIState;
permissions: PermissionState;
// ...
} & {
metadata: MetadataState;
} & SerializableState;
// 正解:interface 有 cache 优势,extends 比 & 快
interface ComplexState extends SerializableState {
user: UserState;
flow: FlowState;
ui: UIState;
permissions: PermissionState;
metadata: MetadataState;
}
TypeScript 对 interface 有"caching"优化,对 type alias 的 intersection 没有。能用 interface 的地方就不用 type,这是 TS 性能优化的隐藏知识。
修法 5:用 type-level 测试代替全量 tsc
// 用 expect-type 做类型测试,代替 tsc --noEmit 全量
import { expectTypeOf } from 'expect-type';
test('FlowExecutor execute return type', () => {
expectTypeOf<FlowExecutor<'http-call'>['execute']>
.returns.resolves.toEqualTypeOf<HttpOutput | ExecutionError>();
});
// 这种测试只检查特定类型,不需要全量推导,极快
把"类型质量"从"全量 tsc"变成"针对性测试",CI 跑得快得多。
修复前后基准
| 指标 | 修复前 | 修法 1+2 后 | 全部修法后 |
|---|---|---|---|
| Type Instantiations | 1.4 亿 | 2700 万 | 650 万 |
| tsc --noEmit 全量 | 4:30 | 1:30 | 38 秒 |
| VSCode 单文件检查 | 15 秒 | 4 秒 | 0.8 秒 |
| tsserver 内存峰值 | 5.2GB | 2.8GB | 1.4GB |
| 开发者日均等待 | 1.5 小时 | 30 分钟 | 5 分钟 |
| 每日团队总等待 | 16.5 小时 | 5.5 小时 | 55 分钟 |
2 周治理把团队每天损失的等待时间从 16.5 小时降到不到 1 小时,按 11 人前端按人天 ¥1500 算,每月省下约 ¥31 万的隐性成本——比我们投入的 80 人日重构成本高 2.5 倍。TS 性能优化的 ROI 极高,但很少有团队会做,因为它不会引发"事故",只是默默吃掉开发效率。
决策树:TS 性能优化路径
我们立的 13 条 TS 性能纪律
- 每周跑一次 tsc --extendedDiagnostics:Instantiations 趋势上涨要警觉;
- CI 加 tsc 性能卡口:全量 tsc 超过 60 秒直接 fail PR;
- 禁止超过 30 个成员的 union type:超出要按业务拆分;
- Distributive conditional type 限定深度 ≤ 5:超出要重新设计;
- 递归类型必须有显式深度参数:Prev[Depth] 模式必用;
- 优先 interface,谨慎 type alias:大型结构必须 interface;
- 泛型参数不超过 3 个:超出要重新审视设计;
- 避免 keyof 大对象 + mapped type:成本随对象大小 O(n²);
- 避免 infer 多次嵌套:每层 infer 都触发完整类型重计算;
- 使用 type-level 测试 expect-type:不要靠 tsc --noEmit 全量;
- 第三方库 .d.ts 要审查:不要随便引用类型复杂的库;
- 新功能要做"类型预算":估算引入的 Instantiations,超预算需 review;
- 项目模块化 + project references:tsconfig 拆成多个项目,增量编译。
引申一:为什么 TS 性能问题被忽视
大部分团队对 TS 性能问题"看不见":
- 没有专门监控:不像运行时性能有 APM,TS 编译性能没人盯;
- 渐进式恶化:每周慢一点点,等到不能忍才发现;
- "对策"是加内存:VSCode 卡了就加内存,而不是查类型设计;
- "类型越复杂越好"的迷信:有人觉得用更高级类型显得技术好,反而把项目搞慢。
TS 性能是工程效率问题,不是炫技问题。简单清晰的类型 > 极致精确但慢的类型——后者让开发者等待,前者让开发者愉快。
引申二:为什么不直接 skipLibCheck
有人会说"开 skipLibCheck: true 不就好了?"。我们试过,只能省 15% 时间,因为我们的瓶颈在自己的类型,不在第三方库。skipLibCheck 适合"自己代码干净,第三方库 d.ts 不干净"的项目,我们是反过来——自己的类型设计有问题。所以 skipLibCheck 治标不治本,该修自己代码就修。
引申三:Project References 的真正价值
修复完之后我们引入了 TS Project References,把项目按业务模块拆成 8 个 sub-project:
// tsconfig.json (root)
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/ui" },
{ "path": "./packages/api" },
{ "path": "./packages/flow-engine" },
{ "path": "./packages/components" },
{ "path": "./packages/utils" },
{ "path": "./packages/types" },
{ "path": "./packages/app" }
]
}
// packages/app/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"references": [
{ "path": "../core" },
{ "path": "../ui" }
]
}
Project References 的好处是增量编译——改一个 package,只重编它和依赖它的 package,而不是整个 monorepo。我们引入后,改一行代码的增量编译从 38 秒降到 4 秒。代价是 tsconfig 配置变复杂,需要团队学习。
引申四:TS 5.x 的"isolated declarations"特性
TypeScript 5.5+ 引入了 isolatedDeclarations 选项,要求每个导出的类型必须显式标注,不依赖类型推导。这看起来繁琐,但实际上对性能极有帮助——因为编译器不再需要跨文件推导导出类型。我们计划下个季度迁移,预计 tsc 时间能再降 30%。
这个特性反映了 TS 团队的思路:用更多的"明确"换取更快的"编译"。早期 TS 强调"类型推导,少写注释",现在反过来鼓励"显式标注,少推导"。在大型项目里这个 trade-off 是值得的。
引申五:类型设计的"最少惊讶"原则
这次治理给我们一个普适的原则:类型设计要"最少惊讶"——让人看一眼就明白,不要让人去解析复杂的类型表达式。比如:
| 反例 | 正例 |
|---|---|
| 140 成员 union | 8 个分组的 discriminated union |
| 5 层泛型嵌套 | 2 层泛型 + 显式 type alias |
| recursive conditional type | 明确深度限制的递归 |
| 大量 intersection | interface extends |
| infer 嵌套提取 | 显式类型工具函数 |
"复杂类型"不是技术能力的体现,是设计能力不足的表现。能用简单类型解决的事情,绝不用复杂类型。这条原则不仅让代码快,也让团队成员易于理解和维护。
引申六:类型性能 vs 类型严格性的 trade-off
TS 项目的两个极端:一是放任 any,类型检查飞快但没有保障;二是极致严格,exactOptionalPropertyTypes + 大量条件类型,慢得不能忍。我们的实践是"中庸":
- 核心业务类型严格:订单、支付、用户等关键类型,值得花成本严格;
- 边缘类型可以宽松:UI 组件 props、工具函数,适度严格即可;
- 测试代码用宽松配置:测试时为了灵活性,可以 strict: false;
- 第三方接口适配层:外部数据进来 zod 验证一次,内部就是 plain object 不用复杂类型。
严格性是工具,不是目的。用对地方,事半功倍;用错地方,事倍功半。
引申七:从 TS 性能事故反思"工程师的隐性成本"
这次事故的真正损失不是 80 人日的修复成本,而是之前 6 个月里团队每天损失的 16.5 小时。算下来 6 个月就是大约 3000 小时,按 ¥1500/天 / 8 小时 = ¥187.5/小时,损失约 ¥56 万——比 80 人日的修复成本(¥12 万)高 4.6 倍。
这种"隐性成本"在工程团队里非常普遍:慢的构建、慢的测试、慢的部署、慢的开发环境……每一个都不会引发"事故",但都在默默消耗团队效率。工程师等待时间是最贵的成本,远高于服务器成本。优化工具链的 ROI 几乎总是正的,但很少有团队主动投资。这次事故让公司高层意识到"工具链优化"是值得专项投入的,后来批了一个全职 DX(开发者体验)岗位。
引申七·五:typescript-eslint 规则在 TS 性能上的作用
我们后来发现 typescript-eslint 里有一组性能相关的规则,启用后能在代码 review 阶段就阻止性能反模式入仓:
// .eslintrc.json - TS 性能相关规则
{
"extends": ["plugin:@typescript-eslint/recommended-type-checked"],
"rules": {
// 禁止大 union type
"@typescript-eslint/no-redundant-type-constituents": "error",
// 强制 readonly 减少 wide 类型
"@typescript-eslint/prefer-readonly": "error",
// 禁止 any(any 会让 TS 跳过推导,但污染下游)
"@typescript-eslint/no-explicit-any": "error",
// 禁止 unnecessary conditional
"@typescript-eslint/no-unnecessary-condition": "error",
// 强制使用 type imports 减少运行时开销
"@typescript-eslint/consistent-type-imports": [
"error",
{ "prefer": "type-imports" }
]
}
}
这些规则不仅提升运行时性能,也减少类型推导成本。比如 consistent-type-imports 强制用 import type,这样的 import 不会出现在 emit 的 JS 里,但更重要的是 TS 在做模块依赖图分析时会跳过这些类型导入,显著减少图的规模。我们启用后 tsc 又快了 8%。不要小看 lint 规则的累积效果,它们是"防止退化"的关键。
引申八:类型测试工具 vitest + expect-type 实战
引入 expect-type 后,我们建了一套类型测试套件,代替部分 tsc --noEmit 的工作。下面是真实的测试样例:
// tests/types/flow-executor.test-d.ts
import { expectTypeOf, describe, it } from 'vitest';
import { FlowExecutor } from '../../src/flow/executor';
import type { HttpOutput, ExecutionError, NodeConfig } from '../../src/types';
describe('FlowExecutor 类型契约', () => {
it('execute 返回类型必须是 NodeOutput | ExecutionError', () => {
expectTypeOf<FlowExecutor<'http-call'>['execute']>
.returns
.resolves
.toEqualTypeOf<HttpOutput | ExecutionError>();
});
it('node 参数类型应根据 TNode 推导', () => {
type ExecuteParams = Parameters<FlowExecutor<'db-query'>['execute']>;
expectTypeOf<ExecuteParams[0]>.toEqualTypeOf<NodeConfig<'db-query'>>();
});
it('禁止传入未注册的 NodeKind', () => {
// @ts-expect-error 'unknown-kind' 不在 NodeKind union 里
new FlowExecutor<'unknown-kind'>();
});
});
这种 test-d.ts 文件不参与运行时执行,只做类型校验。vitest 通过 --typecheck flag 跑这些文件,每次 CI 大约 8 秒(对比 tsc 全量 38 秒)。类型测试不是替代 tsc,而是把"全量推导"变成"针对性断言",关键的契约用测试保护,而其他地方靠 tsc 兜底。
引申八·五:Vite + esbuild 在开发期为什么"看起来快"
有人会问:"我用 Vite,改代码 200ms 就 HMR 了,为什么还说 TS 慢?" 这里有个常见误解需要澄清。Vite 在开发期用 esbuild 做类型 strip(只剥离类型语法,不做类型检查),所以编译极快。但 IDE 里的 tsserver 仍然在跑完整的类型检查,这才是"VSCode 卡 15 秒"的源头。Vite 的快和 tsserver 的慢是两个独立的事情:
| 工具 | 做什么 | 速度 | 是否触发我们的性能问题 |
|---|---|---|---|
| esbuild(Vite 用) | 剥离类型语法 | 毫秒级 | 否(不做类型检查) |
| swc(Next.js 用) | 同上 | 毫秒级 | 否 |
| tsserver(VSCode 用) | 完整类型检查 + 推导 | 15 秒 | 是 |
| tsc --noEmit(CI 用) | 同上 | 4:30 | 是 |
所以"用 Vite 但不用 tsc"的项目可能感受不到性能问题,但它们也没有"类型安全保证"——上线后会有大量类型错误漏到生产。我们的实践是Vite 做开发期 HMR,tsc --noEmit 做 CI 守门,两者结合。Vite 给你速度,tsc 给你保障。
引申九:monorepo 中跨包共享类型的最佳实践
我们项目从单包变成 monorepo 后,跨包共享类型成了新问题。一开始我们直接 import 其他包的内部类型,导致包之间循环依赖,tsc 变得更慢。后来梳理出一套规矩:
| 类型共享场景 | 反模式 | 正解 |
|---|---|---|
| 跨包共享业务类型 | import 内部 .ts 文件 | 抽到 @app/types 独立包 |
| 跨包共享工具类型 | 复制粘贴 DeepPartial 等 | type-fest 库统一引用 |
| 跨包导出 API 类型 | 导出实现类型(含泛型递归) | 导出 simplified 后的类型 |
| 多版本兼容 | 导出 union 多版本 | 用 namespace 隔离版本 |
"导出 simplified 后的类型"是个关键技巧——内部类型可以复杂(为了类型安全),但导出给下游的类型要做一层"压平"。比如内部用 type ApiResponse<T extends NodeKind> = ConditionalLookup<T>,导出时压平成 type ApiResponse = HttpResp | DbResp | TransformResp。下游导入时不再触发递归推导,性能大幅提升。
引申九·五:类型反模式的"传染性"
这次治理中我们观察到一个很重要的现象——类型反模式有传染性。一个 142 成员的 union type 不只是自己慢,它会污染所有引用它的类型。我们做了实验:
- 在 142 成员 union 上加一个 distributive conditional type,直接生成 142 个分支;
- 把这个生成的类型作为另一个泛型的输入,实例化次数变成 142 × N;
- 这个二级泛型如果在 50 个地方使用,实例化次数变成 142 × N × 50;
- 组合爆炸:很快就到千万级、亿级。
所以排查时不能只看"哪个文件慢",要看"哪个根类型最复杂"。我们的 142 成员 union 是 80% 性能问题的根因,治理一处治理全身。这给我们一个启示:类型设计有"层级"——基础类型(底层 union、interface)的简洁度,决定了整个系统的类型性能上限。基础类型一旦做错,上面再多优化都白搭。所以在设计基础类型时,要花 10 倍的时间想清楚,这是 ROI 最高的环节。
引申九·六:常见库的"类型成本"陷阱
第三方库的 .d.ts 也是性能黑洞。我们排查时发现几个流行库的类型定义触发了我们项目的性能问题:
| 库 | 类型成本问题 | 规避方法 |
|---|---|---|
| zod 3.x | recursive infer 在大 schema 上慢 | schema 文件加 // @ts-ignore 或拆分 |
| tRPC v10 | 巨型路由类型推导慢 | 用 v11+ 或 skipLibCheck |
| react-hook-form | FieldPath 递归类型深 | 显式标注 field path 类型 |
| type-fest | 少数工具类型复杂度高 | 只 import 必要工具 |
引入新库前,可以用 trace 工具评估它对你项目类型性能的影响。这是个被严重忽视的环节——大家选库看功能、看 bundle size、看 maintenance,很少看"类型成本"。但在 strict TS 大型项目里,类型成本可能比 bundle size 更重要。我们后来在选型评审清单里加了一条:"类型成本评估——引入此库后 Instantiations 增长不能超过 5%",超过的话必须有充分理由,或者用 skipLibCheck 隔离这一个库。这条规矩让我们后续避免了 3 次"看起来很美最后慢死项目"的库引入。
引申十:TS 性能 vs 重构灵活性的张力
这次治理还留下了一个长期的内部争议:简单类型方便重构,但复杂类型表达力强,如何平衡? 有些场景确实需要复杂类型才能表达精确的约束,比如 SQL builder 需要根据 select 字段推导 row 类型。这种地方我们的实践是:
- 复杂类型"局部化":只在一两个文件里出现,不让它扩散;
- 必须有"复杂类型说明文档":在文件头注释里解释这个类型为什么需要这么复杂、如何调试;
- 必须有性能测试:这种文件单独跑 trace,确认 Instantiations 在可接受范围;
- 定期 review:每季度看一次,如果业务场景不再需要这种精确度,简化掉。
这条原则的本质是把复杂类型当成"昂贵的工具"使用,而不是默认武器。就像生产环境用了 REST 的 over/under-fetch。">GraphQL,不代表内部所有 API 都要 GraphQL——根据场景选工具,不要被工具选择。我们公司另一个团队曾经为了"类型完美",在所有 utility function 都用了高级泛型推导,结果他们的项目类型检查时间是我们的 3 倍,而业务复杂度反而比我们简单。复杂不等于好,合适才是好。这条认知是 18 个月血泪和 420 工时换来的真理,值得每一个 TS 团队记在心里、写进规约、传给新人,避免重蹈覆辙。
总结
这次 TS 性能治理把单文件检查从 15 秒压到 0.8 秒,核心是"识别 + 拆分 + 简化"三步走:用 extendedDiagnostics 识别问题、用 analyze-trace 定位热点、用 5 种修法逐个简化。没有任何"魔法",全是基于 TS 类型计算机制的工程优化。这条治理路径有几个关键认知值得反复强调:第一,Instantiations 是 TS 性能的核心指标,必须监控;第二,巨型 union + 递归条件类型 + 过度泛型是三大反模式,通常会叠加出现;第三,interface 比 type alias 性能好,这是隐藏知识但极其重要;第四,Project References + isolatedDeclarations 是中大型项目的必选,不是可选。
更重要的认知是:TypeScript 是一门图灵完备的"类型编程语言",写它的代码也需要工程纪律。你可以用 TS 写出极简的类型(快、可读),也可以写出极复杂的类型(慢、难懂)。差别不是"水平高低",是"设计原则"。我们 18 个月前写的复杂泛型,现在看回去都觉得"何必呢"——当时为了 5% 的额外类型安全,付出了 10 倍的性能代价,完全不划算。如果你的项目 tsc 全量超过 60 秒,今天就该开始治理——不要等到 4 分 30 秒才动手。技术债的特点就是利息会指数级增长,等到不能忍才修,代价已经是修法本身的 5-10 倍。早一天动手,早一天受益,这是工程治理永恒的真理。
—— 别看了 · 2026