本节目标:掌握
infer在条件类型里做模式匹配与类型提取的规则,理解推断位置与协变/逆变的关系,学会用递归条件类型实现类型级循环,并知道如何规避「Type instantiation is excessively deep」这类深度超限错误。
2.2 infer 与递归
上一节的条件类型只能做「是 / 否」判断。真实需求往往还要把类型里的某个部分取出来:函数的返回值、数组的元素、Promise 的 resolve 值、模板字符串里的参数名。infer 就是为此设计的;而一旦有了提取能力,配上递归,类型系统就变成了可循环的程序。
一、infer 的基本形态
infer 只能出现在条件类型 extends 的右侧,用来声明一个推断变量:
type ElementOf<T> = T extends (infer U)[] ? U : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<number[]>; // number
type C = ElementOf<{ x: 1 }>; // never
读法:「若 T 可赋值给某个元素类型为 U 的数组,则把 U 提取出来」。infer U 的位置相当于一个占位符,TypeScript 在匹配过程中解出 U 的具体类型。
infer 可以出现多次,也可以配合变长元组(variadic tuple)语法做「取头 / 取尾」:
type First<T> = T extends [infer F, ...unknown[]] ? F : never;
type Last<T> = T extends [...unknown[], infer L] ? L : never;
type F1 = First<[string, number, boolean]>; // string
type L1 = Last<[string, number, boolean]>; // boolean
...unknown[] 是这一层的基石——没有它就无法表达「其余部分」。后面所有递归遍历元组的写法都建立在它之上。
二、推断位置的协变与逆变
infer 出现在函数参数位置时,推断结果会受逆变影响:
type ParamOf<T> = T extends (x: infer P) => void ? P : never;
type ReturnOf<T> = T extends (...args: never[]) => infer R ? R : never;
type P = ParamOf<(a: string) => void>; // string
type R = ReturnOf<(a: string) => number>; // number
对重载函数,infer 只会取最后一个重载签名。对同时存在多个候选位置的 infer(例如用 (x: infer P) => void 去匹配多参数函数),匹配会失败或得到 unknown。这也是为什么标准库的 Parameters<T> 用的是 (...args: infer P) => any 而不是单参数形式:
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type Params = MyParameters<(a: string, b: number) => void>;
// [a: string, b: number](带标签的元组)
注意结果是带标签的元组,标签来自原函数的参数名——这在编辑器的参数提示里很有用。
三、给推断变量加约束
TypeScript 4.7 起支持在 infer 上直接写约束,让推断变量只在满足约束时匹配成功:
type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
type S1 = FirstString<['a', 1]>; // 'a'
type S2 = FirstString<[1, 'a']>; // never('1' 不满足 string 约束,匹配失败)
在 4.7 之前只能嵌套一层判断,写法更啰嗦且更容易出现分发意外:
type FirstStringOld<T> =
T extends [infer S, ...unknown[]] ? (S extends string ? S : never) : never;
约束写法还有一个好处:它把「匹配失败」与「匹配成功但类型不符」这两种情况区分得更清晰。做类型级解析时,这个区分能显著减少调试成本。
四、infer 与 readonly、元组修饰
infer 也能穿透 readonly 修饰。用 readonly (infer U)[] 可以同时匹配可变数组与只读数组(元组也满足):
type ElementOfReadonly<T> = T extends readonly (infer U)[] ? U : never;
type ER = ElementOfReadonly<readonly [1, 2, 3]>; // 1 | 2 | 3
type EM = ElementOfReadonly<string[]>; // string
反过来,去掉 readonly 需要显式映射:
type Mutable<T> = T extends readonly (infer U)[] ? U[] : never;
type M1 = Mutable<readonly string[]>; // string[]
type M2 = Mutable<readonly [1, 2]>; // (1 | 2)[] ← 注意元组被展平了
第二行的结果值得警惕:元组一旦经过 (infer U)[] 提取再重建,就丢失了「定长」信息,退化成普通数组。保留定长信息必须用映射类型而非数组提取。
五、infer 与模板字面量类型
infer 也能在模板字面量类型里捕获子串,这是解析字符串字面量类型的关键:
type SplitPath<S extends string> =
S extends `${infer Head}/${infer Rest}` ? [Head, ...SplitPath<Rest>] : [S];
type Segments = SplitPath<'a/b/c'>; // ['a', 'b', 'c']
原理是模板字面量类型的匹配是贪婪的:第一个 infer Head 会尽可能短,把剩余部分交给 Rest。因此 'a/b/c' 得到 Head = 'a'、Rest = 'b/c',再递归下去直到没有斜杠。
一个真实场景是把路由路径里的参数名提取成联合:
type RouteParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | RouteParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type P = RouteParams<'/users/:id/posts/:postId'>; // 'id' | 'postId'
这类工具在需要「从路由定义推导出参数类型」的框架里非常常见,读 zod 数据校验实践 时你会看到同一套思路用在 schema 到静态类型的推导上。
六、字符串工具:从模板字面量到命名转换
模板字面量加 infer 可以做出完整的字符串处理工具,最典型的是命名风格转换:
type KebabToCamel<S extends string> =
S extends `${infer Head}-${infer Rest}`
? `${Head}${Capitalize<KebabToCamel<Rest>>}`
: S;
type C1 = KebabToCamel<'foo-bar-baz'>; // 'fooBarBaz'
type C2 = KebabToCamel<'id'>; // 'id'
把它接到映射类型的 as 子句上,就能批量改写对象键:
type CamelKeys<T> = {
[K in keyof T as K extends string ? KebabToCamel<K> : K]: T[K];
};
type Raw = { 'user-id': number; 'user-name': string };
type Camel = CamelKeys<Raw>;
// { userId: number; userName: string }
这正是后端 API 字段风格转换类工具库的核心实现。但要注意代价:每个键都要递归一次字符串匹配,键的数量乘以片段数就是实例化总量。一个几百个字段的接口类型会让编译时间明显上升,测量方法见 3.1 类型实例化开销与测量 。
七、递归条件类型
条件类型可以引用自身,从而表达循环。最常见的是深度只读:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Config {
server: { host: string; port: number };
tags: string[];
}
type ReadonlyConfig = DeepReadonly<Config>;
// { readonly server: { readonly host: string; readonly port: number };
// readonly tags: readonly string[] }
注意数组也被 object 覆盖,递归后变成了 readonly string[]。写递归类型有两条铁律:
- 必须有一个会「变小」的输入:元组每次去掉一个元素、字符串每次截掉一段,否则就是无限递归。
- 终止分支要显式给出,通常是
never、空元组或原类型本身。
一个典型的「变小」例子是把两个字符串字面量联合组合成笛卡尔积:
type AllPairs<A extends string, B extends string> =
A extends string ? `${A}-${B extends string ? B : never}` : never;
type Pairs = AllPairs<'a' | 'b', '1' | '2'>;
// 'a-1' | 'a-2' | 'b-1' | 'b-2'
这里用了嵌套分发:外层对 A 分发,内层对 B 分发,两层展开相乘得到笛卡尔积。若 A、B 各 10 个成员,结果就是 100 个字符串字面量——编译开销随之上升,3.1 类型实例化开销与测量
会量化这一点。
八、用累积参数避免结果反转
递归时若直接「先递归、后拼接」,结果顺序会反掉:
type Reverse<T extends unknown[]> = T extends [infer F, ...infer R]
? [...Reverse<R>, F]
: [];
type R = Reverse<[1, 2, 3]>; // [3, 2, 1]
要得到正序,惯用手法是加一个累积参数(accumulator),把结果往尾部追加:
type BuildTuple<L extends number, Acc extends unknown[] = []> =
Acc['length'] extends L ? Acc : BuildTuple<L, [...Acc, unknown]>;
type T3 = BuildTuple<3>; // [unknown, unknown, unknown]
Acc['length'] 是元组的长度。这是类型层面唯一的「数值」来源——因为 TS 里没有类型级整数,所有算术与比较都要绕道元组长度来实现,2.3 类型级数据结构与图灵完备
会专门展开。
累积参数还有一个副作用:它让递归调用出现在返回位置的最外层(尾调用),从而放宽深度限制。这就是「尾递归消除」的入口,机制与边界在 3.2 尾递归消除与深度限制 里详解。
九、深度限制与报错
递归深度过大时,编译器会报:
error TS2589: Type instantiation is excessively deep and possibly infinite.
触发阈值不是固定的「多少层」,而与实例化总量相关:普通递归默认上限约 50 层,尾递归形式可放宽到约 1000 层。三条缓解手段:
| 手段 | 做法 | 适用场景 |
|---|---|---|
| 减小单步代价 | 每层少产生中间类型,用别名缓存中间结果 | 普遍适用 |
| 尾递归改写 | 把结果放进参数,避免在返回位置递归 | 累积型递归 |
| 显式深度上限 | 加 Depth extends number 计数器,超限返回 never | 处理不可信输入 |
显式深度上限的写法如下,它把「不可控的编译错误」变成「可控的类型结果」:
type SafeSplit<S extends string, Depth extends unknown[] = []> =
Depth['length'] extends 10
? never
: S extends `${infer H}/${infer R}` ? [H, ...SafeSplit<R, [...Depth, unknown]>] : [S];
type Ok = SafeSplit<'a/b/c'>; // ['a', 'b', 'c']
type TooDeep = SafeSplit<'a/b/c/d/e/f/g/h/i/j/k/l'>; // never(超限)
尾递归改写示例:
type ReplaceAll<S extends string, From extends string, To extends string> =
S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${ReplaceAll<Tail, From, To>}`
: S;
type Replaced = ReplaceAll<'a-b-c', '-', '_'>; // 'a_b_c'
注意这个实现不是尾递归:递归调用出现在模板字面量内部,深度受限。改成累积参数形式后可支撑更长的字符串。
十、常见坑
坑 1:infer 只能出现在 extends 右侧。 写在别处会直接报语法错误,例如 type X<T> = infer U; 无法通过编译。
坑 2:函数类型匹配要写对参数形态。 T extends (x: infer P) => void 对多参数函数匹配失败(参数个数不兼容),应写 (...args: infer P) => void。
坑 3:同一 infer 变量出现在多个位置时的合并规则。 协变位置(属性、返回值)会得到联合,逆变位置(函数参数)会得到交叉:
type Merge<T> = T extends { a: infer U; b: infer U } ? U : never;
type M = Merge<{ a: string; b: number }>; // string | number(协变 → 联合)
type MergeFn<T> = T extends { f: (x: infer U) => void; g: (x: infer U) => void } ? U : never;
type MF = MergeFn<{ f: (x: string) => void; g: (x: number) => void }>; // string & number(逆变 → 交叉)
坑 4:递归没有终止条件。 写完务必用几个边界输入验证:空元组 []、空字符串 ''、以及完全不匹配的类型,确认都落在预期的终止分支上。
坑 5:把元组当数组用会丢信息。 readonly [1, 2] 经过 (infer U)[] 提取后变成 (1 | 2)[],定长与顺序信息都没了。需要保留结构时必须用映射类型逐位置处理。
十一、验证 infer 与递归行为
和条件类型一样,infer 工具只能靠类型断言测试:
type Assert<T extends true> = T;
type IsEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
// 把约束放在具体断言处,不在尚未确定的泛型 A/B 上要求 true
type _1 = Assert<IsEqual<SplitPath<'a/b'>, ['a', 'b']>>;
type _2 = Assert<IsEqual<ReplaceAll<'a-b', '-', '_'>, 'a_b'>>;
type _3 = Assert<IsEqual<Reverse<[1, 2, 3]>, [3, 2, 1]>>;
type _4 = Assert<IsEqual<Last<[1, 2, 3]>, 3>>;
这些断言在 tsc 通过时静默,失败时报「类型 false 不满足约束 true」。把边界用例(空字符串、单元素元组、不匹配类型)也写成断言,是避免递归类型在用户手里炸掉的最省事手段。
小结
infer只能在条件类型的extends右侧声明,用于模式匹配并提取局部类型。- 位置决定推断结果:参数位置受逆变影响;同一
infer变量出现在多处时,协变位置合并为联合、逆变位置合并为交叉。 infer S extends string(TS 4.7+)可直接给推断变量加约束,比嵌套条件更简洁、语义更清晰。- 模板字面量类型里的
infer是解析字符串字面量类型的核心,配合递归可做路由参数、命名风格转换等工具。 - 递归条件类型必须有递减的输入与显式终止分支;用累积参数既能避免结果反转,也能让递归变成尾调用。
- 类型级没有整数,数值运算靠元组
['length']模拟。 - 深度超限会报
TS2589;缓解手段是减小单步代价、尾递归改写或加显式深度上限。
下一节我们把元组、映射与递归组合起来,构建类型级的链表、字典与算术,看看 TypeScript 的类型系统到底「能算」到什么程度。
阅读导航:上一节:2.1 条件类型与分发 · 下一节:2.3 类型级数据结构与图灵完备 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。