1. 技术难点:类型检查为什么也会"算不动"
很多人以为 TypeScript 的类型检查是"查表",其实它是编译期的计算过程。你写的每个泛型、条件类型、模板字面量类型,编译器都要真实地推演一遍。推演和运行代码一样有复杂度,写不好一样会指数爆炸:
- 联合类型展开是 O(n²) 起步 :
keyof T一次产生几十个成员,两个这样的联合做交叉或分布式条件类型,成员数相乘。三层嵌套下来就是几十万次推导。 - 递归没有运行时那样的"栈上限"保护 :类型递归虽然有个
Type instantiation is excessively deep(深度 1000 左右)的报错阈值,但到达报错之前,编译器已经默默烧了很久的 CPU。深层递归在每次展开时还会产生新的瞬时类型,垃圾回收都跟不上。 - 字面量类型爆炸 :模板字面量类型(
${'a'|'b'}${'c'|'d'})会把联合成员做笛卡尔积,4 个两成员联合就是 16 种,10 个就是 1024 种。类型会"算出来",但代价是你的 tsc 慢慢变成 tsc 尸体。 - 交叉类型 vs 接口的隐藏开销 :
type A = B & C在每次属性访问时都要重新做属性合并解析;而interface B extends C是一次性扁平化的,访问成本低一个量级。
痛点 :类型一慢,不只是 tsc 变慢,整个 IDE(VSCode/WebStorm)的智能提示、跳转、重构全部跟着卡。你写的类型越多,团队每个人都在为你的类型体操付编译税。
2. 完整解法:六条让类型快起来的实操
2.1 先诊断,别猜
tsc 自带性能分析器,先看慢在哪再动手:
bash
# 查看每次 check 的时间分布
tsc --extendedDiagnostics
# 生成编译器 trace(JSON 瀑布),用 chrome://tracing 打开
tsc --generateTrace ./trace
--extendedDiagnostics 会列出 Files、Identifiers、Symbols、Type checks、Assignability checks 等计数。重点看 Type checks 和 Assignability checks,这两个数字异常大(百万级)就是类型计算失控的实锤。--generateTrace 能精确到具体哪个文件、哪个类型的展开占了大头。
2.2 联合类型瘦身:少用"分布式"陷阱
条件类型遇到裸类型参数会分布(每个成员单独走一遍分支),这是性能杀手之一:
ts
// 慢:T 是联合时,S 会被逐个成员展开,每个成员都要实例化整个分支
type Filter<T, U> = T extends U ? T : never;
type Big = Filter<A | B | C | D | E, SomeConstraint>; // 5 次实例化
// 快:用 [T] 包住避免分布,一次性判定
type FilterNonDist<T, U> = [T] extends [U] ? T : never;
// 快:映射类型替代分布式过滤(对 keyof 结果特别有效)
type FilterKeys<T, U> = { [K in keyof T]: T[K] extends U ? K : never }[keyof T];
规则:不要求分布语义就别裸用类型参数 ,[T] 包一层,编译器少算 n-1 次分支。
2.3 递归改"尾递归",并控制深度
TypeScript 4.5+ 对尾递归条件类型做了专门优化,递归计数不再计入深度上限。把非尾递归改写成尾递归:
ts
// 慢且深:非尾递归,展开路径是 a -> (b -> (c -> ...)),每层都要留着前文
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// 快:尾递归版(同一目的,展开后栈不累积)
type DeepReadonlyTR<T> = _Deep<T, []>;
type _Deep<T, Acc extends unknown[]> =
T extends object
? { readonly [K in keyof T]: _Deep<T[K], [T[K], ...Acc]> }
: T;
尾递归版本在深层数据结构(层级 50+)下,检查时间可以从秒级降到毫秒级。另外给递归设深度上限 是工程上的好习惯:Depth extends number = 5,到深度就截断,防止输入数据失控把编译器拖死。
2.4 用接口合并代替交叉类型
ts
// 慢:交叉类型,属性访问时反复合并解析
type User = { id: string } & { name: string } & { age: number };
// 快:接口继承,一次性扁平化
interface Base { id: string }
interface Named { name: string }
interface User extends Base, Named { age: number }
交叉类型是惰性结构 ,每次访问 User['id'] 都可能触发合并计算;接口是急结构,声明时就扁平好了。对会被频繁索引的领域模型,用接口收益立竿见影。
2.5 工程层兜底:缓存 + 裁剪
jsonc
// tsconfig.json
{
"compilerOptions": {
"incremental": true, // 增量编译,.tsbuildinfo 缓存
"skipLibCheck": true, // 跳过 d.ts 全量检查(大头开销之一)
"composite": true, // 配合 project references 按模块隔离类型域
"declaration": true
}
}
Monorepo 里最重要的习惯 :把 api 层、shared 层拆成独立 project(project references),每个包只检查自己 + 依赖的 d.ts,而不是一个大 tsconfig 全量检查所有文件。类型计算和运行时一样,分区隔离是根治。
2.6 类型体操戒律:给不确定的边界设"逃生舱"
不是所有地方都需要精确类型。路由参数、外部数据、表单回填这类边界不稳定 的场景,明确用 unknown + 局部收窄,而不是硬写一个推导 10 层泛型的类型把全项目拖下水:
ts
// 不推荐:为一个不稳定接口写巨型推导
type RouteParams<T> = T extends `${infer _}:${infer P}/${infer R}` ? P | RouteParams<R> : never;
// 推荐:边界用 unknown 隔离,业务层局部收窄
function parseRoute(path: string, schema: ZodSchema<unknown>) {
return schema.safeParse(path); // 类型检查成本 O(1)
}
写类型之前先问一句:这个类型的计算成本,换来了多少编译期安全? 算不过账的类型就该砍。
3. 应用场景
- 大型 Monorepo :几十个包共享一个
tsconfig时,一个慢类型会让所有人的 IDE 变砖。按 2.5 分区 + 2.2 瘦身,是发布前必查项。 - 组件库/设计系统 :
Props泛型被React.FC、forwardRef、mergeProps层层包装,瞬时联合疯狂膨胀。给Props系列类型做性能测试(--generateTrace),能救回组件库的 DX 口碑。 - 路由/表单的类型推导 :模板字面量类型做
'/users/:id'推导很帅,但联合过大时(几十条路由 × 参数类型)就是 O(n²) 灾难,需要按 2.2 缓存推导结果或降级为运行时校验。
4. 总结
类型性能的本质是编译期预算 :每个联合、每次递归、每层交叉都在花预算。四条核心纪律:能分布就非分布([T] 包裹)、能尾递归就尾递归、能用接口就别用交叉、能分区就不全量。配上 --extendedDiagnostics 先诊断再动手,你的 tsc 从"卡 3 秒"到"丝滑增量"往往只差这几个改动。类型是写给编译器看的程序,好类型和好代码一样:可读、可控、可估算成本。