《TypeScript高级编程》3.3 复杂泛型的重构手法

本节给出一套把又慢又深又难读的泛型改小的可操作手法:先用三个信号判断「该不该重构」,再逐一演示具名化中间结果、用 interface 承载形状、把校验挪到约束上、用映射表替代条件链、关闭意外分发、把计算下沉到代码生成、以及用类型测试锁定行为七种改法。每种手法都给出改前改后对照与实例化代价的变化,最后用一份评审清单把重构收成可复用的流程。

本节目标:读完这一节,你能用三个信号判断一份泛型是否到了必须重构的程度;能熟练使用具名化中间结果、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">;

两个要点:

  1. IsEqual 用函数签名比较,能区分 any 与具体类型、也能区分 { a: 1 } 与 { a: 1; b?: never }——直接写 A extends B ? true : false 做不到这一点。
  2. @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) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes