本节目标:读完这一节,你能用三个信号判断一份泛型是否到了必须重构的程度;能熟练使用具名化中间结果、
interface承载形状、约束前置、映射表替代条件链、关闭意外分发、计算下沉与类型测试这七种手法;能对同一个类型工具给出「改前 / 改后」的实例化代价对比,并知道哪几种改动是不损失类型强度的等价重构。
3.3 复杂泛型的重构手法
前两节我们有了两把尺子:3.1 用 Instantiations 量出「多贵」,3.2 用三道闸门说清「天花板在哪」。这一节回答最后一个问题——当一份泛型已经又慢又深又难读,怎么把它改小而不改坏?
先把话说在前面:类型重构与运行时重构有一条根本差异。运行时代码重构后可以靠测试断言「行为不变」;类型代码的「行为」是编译期的推导结果,唯一能锁定它的手段是类型测试。所以本节最后一种手法(类型测试)不是锦上添花,而是所有其他手法的安全网。
三个信号:什么时候该重构
不是所有复杂类型都该重构。类型工具是资产,改写有成本也有风险。出现下面三个信号之一,才值得动手:
| 信号 | 具体表现 | 属于哪一节的问题 |
|---|---|---|
| 撞上限 | 报 TS2589,或改一处就让全项目编译失败 | 3.2 尾递归消除与深度限制 |
| 超预算 | Instantiations 明显高于基线,Check time 同步上涨 | 3.1 类型实例化开销与测量 |
| 不可读 | 报错信息里出现十几个匿名类型、团队里只有一个人敢改 | 本节 |
反过来,如果一个类型「看起来很吓人」但从未出现在 trace 排行榜上、也从没拦过任何人,不要动它。恐惧不是重构的理由,数据才是。
手法一:具名化中间结果
最常见的泛型问题不是「逻辑错」,而是把所有逻辑塞进一个表达式。嵌套条件类型会让编译器失去复用的机会——每一层的结果都没有名字,也就无法被缓存。
// 改前:一个类型里塞了四层推导,中间结果全是匿名的
type RouteConfig<T extends string> = T extends `${infer Method} ${infer Path}`
? Method extends "get" | "post"
? Path extends `${string}:${infer Param}`
? { method: Uppercase<Method>; params: Param }
: { method: Uppercase<Method>; params: never }
: never
: never;
// 改后:每层推导有名字,编译器可以复用求值结果
type ParseRoute<T extends string> =
T extends `${infer Method} ${infer Rest}` ? { method: Method; path: Rest } : never;
type ToUpperMethod<T extends string> = T extends "get" | "post" ? Uppercase<T> : never;
type ExtractParams<P extends string> =
P extends `${string}:${infer Param}` ? Param : never;
type RouteConfig2<T extends string> =
ParseRoute<T> extends { method: infer M extends string; path: infer P extends string }
? { method: ToUpperMethod<M>; params: ExtractParams<P> }
: never;
type C1 = RouteConfig2<"get /user/:id">; // { method: "GET"; params: "id" }
为什么这样更快:具名别名让编译器对同一组类型参数只求值一次,并在多处引用时复用。代价是多几个类型名——这是值得付的代价,因为类型名本身就是文档。
手法二:用 interface 承载对象形状
3.1 讲过 interface 惰性求值且可被增量缓存,而交叉类型在声明处就要归并。当一份「大对象类型」由多个片段拼成时,优先用接口继承:
// 改前:五个交叉,每次引用都可能重新归并成员
type UserEntity = Identity & Timestamps & SoftDelete & AuditFields & TenantScope;
// 改后:成员归并只做一次,且可被增量编译复用
interface UserEntity extends Identity, Timestamps, SoftDelete, AuditFields, TenantScope {}
// 需要联合或推导时,再用 type 引用接口
type NullableUser = UserEntity | null;
判据很清晰:描述「一个对象的形状」用 interface,描述「一组可能性的集合」用 type。这一条几乎能消掉大半「莫名其妙变慢」的案例。
手法三:把校验从类型体挪到约束上
一个常见的反模式是「在类型体里判断参数是否合法,不合法就返回 never」。这会让错误信息出现在很深的地方,而且每次使用都要走一遍判断:
// 改前:非法输入返回 never,错误信息指向内部实现,用户看不懂
type PickFields<T, K extends string> =
K extends keyof T ? Pick<T, K> : never;
type Bad1 = PickFields<{ id: number }, "name">; // never(用户不知道为什么)
// 改后:用约束把校验前置,错误出现在调用处,且约束只检查一次
type PickFields2<T, K extends keyof T> = Pick<T, K>;
type Bad2 = PickFields2<{ id: number }, "name">;
// 类型 '"name"' 不满足约束 'keyof { id: number }'。ts(2344)
改后有两个收益:错误信息可读(直接说哪个参数不满足哪个约束)、求值次数减少(约束检查比分支判断轻)。这条手法与 2.1 条件类型与分发 里「把约束写进泛型声明」的建议是同一个道理。
手法四:用映射表替代条件链
「根据一个字符串键选择不同结果」的需求,写成条件链会随分支数线性变长,而且新增分支必须改类型体。改成映射表后,新增分支只是加一行数据:
// 改前:条件链,每加一种格式就再加一层
type Payload<T extends string> = T extends "json"
? { kind: "json"; raw: string }
: T extends "xml"
? { kind: "xml"; raw: string }
: T extends "csv"
? { kind: "csv"; raw: string }
: never;
// 改后:映射表 + 索引访问,求值退化成一次查表
interface PayloadMap {
json: { kind: "json"; raw: string };
xml: { kind: "xml"; raw: string };
csv: { kind: "csv"; raw: string };
}
type Payload2<T extends keyof PayloadMap> = PayloadMap[T];
映射表还有一个隐含好处:它天然是一个约束。T extends keyof PayloadMap 会自动把非法键拦在调用处,不需要额外的分支返回 never。
手法五:关闭意外分发
联合类型在条件类型里会自动分发,每个成员各算一遍。想要「整体处理」时忘了关分发,是实例化翻倍的常见原因:
// 改前:分发,联合有几个成员就实例化几次
type Wrap<T> = T extends unknown ? { value: T } : never;
type W1 = Wrap<A | B | C>; // { value: A } | { value: B } | { value: C }
// 改后:用 [T] 关闭分发,整体只算一次
type WrapAll<T> = [T] extends [unknown] ? { value: T } : never;
type W2 = WrapAll<A | B | C>; // { value: A | B | C }
两者语义不同,先想清楚要哪种语义再决定是否关分发。如果确实要分发(比如 Exclude、NonNullable 那种逐成员过滤),那就保留,但要知道成本与成员数同阶;成员数可能上百时,改用运行时过滤更划算。
手法六:把计算下沉到代码生成
这是最容易被忽略、收益却最大的一条。类型层不该做它能不做的事。 当某个推导需要上千层深度、或者要处理几千个键时,正确答案往往不是「把类型写得更巧」,而是「在构建期把结果算出来」。
// 改前:从 zod schema 反推类型,schema 越大实例化越多
const schema = z.object({ /* 300 个字段 */ });
type FormData = z.infer<typeof schema>; // 每次引用都要重新推导
// 改后:构建期把推导结果落成 .d.ts,运行时只做校验
// scripts/gen-types.ts 里:fs.writeFileSync("src/generated/form-data.d.ts", emit)
import type { FormData } from "./generated/form-data";
declare const data: FormData;
三个判据决定「该不该下沉」:
| 条件 | 说明 |
|---|---|
| 输入是静态数据 | 数据不变时结果就不变,天然适合预计算 |
| 推导只在构建期需要 | 运行时不需要该类型参与任何计算 |
| 结果规模大但形态稳定 | 例如从 schema、OpenAPI、proto 生成的类型 |
第 2.3 类型级数据结构与图灵完备 节的结论在这里兑现:类型系统确实图灵完备,但**「能算」不等于「该在这里算」**。
手法七:用类型测试锁定行为
前面六种手法都会改变类型的求值方式。怎么保证「改小」没有「改坏」?唯一可靠的手段是类型测试——把期望的推导结果写成断言,让编译器替你守着。
type IsEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type A = { a: 1 };
type B = { b: 2 };
type C = { c: 3 };
type _1 = Expect<IsEqual<WrapAll<A | B | C>, { value: A | B | C }>>;
type _2 = Expect<IsEqual<Payload2<"json">, { kind: "json"; raw: string }>>;
type _3 = Expect<IsEqual<RouteConfig2<"get /user/:id">, { method: "GET"; params: "id" }>>;
// 反向断言:确认非法输入确实被拒绝,且错误在预期位置
// @ts-expect-error "name" 不是 keyof { id: number }
type _4 = PickFields2<{ id: number }, "name">;
// @ts-expect-error 未知格式键被约束拦住
type _5 = Payload2<"yaml">;
两个要点:
IsEqual用函数签名比较,能区分any与具体类型、也能区分{ a: 1 }与{ a: 1; b?: never }——直接写A extends B ? true : false做不到这一点。@ts-expect-error是断言不是抑制:如果下一行不再报错,它自己会变成错误。这正是我们要的效果——重构后行为若变化,测试立刻失败。
类型测试的写法在既有专题 /typescript-testing-type-safe/ 里有更系统的展开。
案例:从 12 层嵌套到 3 个具名类型
一个真实的表单库类型,改前是一棵 12 层深的嵌套条件类型,Instantiations 贡献约 42 万,报错信息长度超过 200 行。
// 改前(节选):每层都嵌在上一层里,没有任何中间名字
type FieldValue<
S extends Record<string, FieldSpec>,
K extends keyof S,
> = S[K] extends { type: "text" }
? S[K] extends { optional: true }
? string | undefined
: string
: S[K] extends { type: "select" }
? S[K] extends { options: readonly (infer O)[] }
? O extends string
? S[K] extends { optional: true }
? O | undefined
: O
: never
: never
: never;
按本节手法逐条改造:
// 1) 拆出「叶子类型」的映射表,替代最外两层条件链
interface LeafType {
text: string;
number: number;
boolean: boolean;
}
// 2) 拆出「可空化」这一个横切关注点,具名化
type Maybe<T, Opt extends boolean | undefined> = Opt extends true ? T | undefined : T;
// 3) 拆出「select 选项提取」这一层推导,单独具名
type OptionsOf<Spec> = Spec extends { options: readonly (infer O extends string)[] } ? O : never;
// 4) 主类型只剩下三层,且每层都引用具名结果
type FieldValue<
S extends Record<string, FieldSpec>,
K extends keyof S,
> = S[K] extends { type: infer T extends keyof LeafType }
? Maybe<
T extends "select" ? OptionsOf<S[K]> : LeafType[T],
S[K] extends { optional: true } ? true : undefined
>
: never;
结果:
| 指标 | 改前 | 改后 |
|---|---|---|
| 嵌套层数 | 12 层 | 3 层 + 3 个具名辅助类型 |
Instantiations 贡献 | 约 42 万 | 约 11 万 |
| 报错信息长度 | 200+ 行匿名类型 | 5 行,直接指向 FieldSpec 约束 |
| 新增字段类型的改动点 | 改 3 处嵌套分支 | 在 LeafType 加一行 |
关键是第 3 步:OptionsOf 被提出来后,不止这个类型能用,表单渲染、校验、序列化三处都可以复用它——这正是具名化的复利。
重构评审清单
把本节手法收成一份可以贴在 PR 模板上的清单:
| 检查项 | 期望 | 对应手法 |
|---|---|---|
| 是否有超过 4 层的嵌套条件类型 | 拆成具名别名 | 手法一 |
对象形状是否用 interface 描述 | 是;交叉只用于类型组合的少数场景 | 手法二 |
| 非法输入是否在调用处报错 | 是;用 extends 约束而非返回 never | 手法三 |
| 是否存在随分支数变长的条件链 | 改成映射表 | 手法四 |
| 是否需要分发 | 明确写出意图,不靠默认行为 | 手法五 |
| 推导输入是否为静态数据 | 是则考虑构建期生成 | 手法六 |
| 是否有类型测试覆盖边界 | 正反断言齐备 | 手法七 |
是否有 Instantiations 前后对比 | 是;重构必须带数据 | 3.1 测量 |
是否撞过 TS2589 | 撞过则同时评估尾递归与计数器 | 3.2 深度 |
一句话总结这份清单的取向:类型的复杂度应该花在「表达业务约束」上,而不是花在「绕开编译器限制」上。 当一份类型里大量篇幅在处理深度、分发与缓存时,它已经在做编译器该做的事了。
延伸阅读:泛型 API 的设计与性能取舍可参考既有专题 /typescript-generic-api-design-performance/ 与 /typescript-advanced-types/ ;编译期性能治理全景见 /typescript-build-performance-optimization/ 与 /frontend-typescript-performance/ 。
小结
- 重构的触发条件是数据而非恐惧:撞
TS2589、Instantiations超预算、报错信息不可读,三者之一才动手。 - 具名化中间结果让编译器复用求值,是收益最直接的一招;类型名同时充当文档。
- 对象形状用
interface(惰性 + 可缓存),可能性集合用type;交叉类型只在少数组合场景使用。 - 把校验从类型体挪到泛型约束上,既让错误出现在调用处,又省掉每次使用的分支判断。
- 随分支数变长的条件链应改成映射表,它同时天然充当约束。
- 联合的意外分发是实例化翻倍的常见原因;用
[T] extends [U]关闭,但先确认语义是否需要分发。 - 输入是静态数据、推导只在构建期需要时,下沉到代码生成比把类型写得更巧更划算。
- 类型测试是重构的安全网:
IsEqual区分any与具体类型,@ts-expect-error反向锁定「该拒绝的必须拒绝」。 - 重构必须带
Instantiations前后对比;没有数据的重构只是换个写法。
至此第 3 章「类型性能治理」结束。三节连成一条完整的链路:3.1 量化成本 → 3.2 认清天花板 → 3.3 安全地改小。你会发现这一章几乎没有讲新的类型语法,讲的都是「同一件事怎么写得让编译器轻松」。下一章我们转向运行时——从 4.1 标准装饰器(TS 5.x) 开始,看类型擦除之后,我们还能在运行时抓住多少信息。
阅读导航:上一节:3.2 尾递归消除与深度限制 · 下一节:4.1 标准装饰器(TS 5.x) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。