Skip to main content

TS 泛型进阶与 infer

infer 让你在条件类型里「把某个位置的类型抓出来」。它是类型推导从「判断」走向「提取」的关键。

1. 在条件类型里抓类型​

infer 只能出现在条件类型的 extends 之后,作用是「把某个位置上的类型抓出来存进一个变量」。它是类型推导从「判断」走向「提取」的分界:没有 infer 时条件类型只能回答是或否,有了 infer 才能把答案带出来。内置工具类型里 ReturnType、Parameters、Awaited 全靠它实现。

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type Parameters<T> = T extends (...args: infer P) => any ? P : never
// 教学用的简化版:只处理 Promise。
// TS 内置的 Awaited 还要处理 thenable 对象、null/undefined 与跨层嵌套,
// 这里写的是推导思路,不是内置实现的等价替换。
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
infer 做的事:在类型里挖一个位置出来,给它起个名字type R = T extends (a: infer P) => infer V ? [P, V] : neverP = 第一个参数的类型V = 返回值的类型同一条规则能用在数组、函数、Promise 上——只是「挖的位置」不同,拿到的类型就不同。
图:infer 的抓取位置——它本身不挑类型,挑的是你在哪一层挖

2. 提取参数与返回值​

2.1 写在参数里和写在返回值里​

一个容易被忽视的细节是位置决定变型:infer 出现在返回值位置(协变)时,联合类型会被分别推导再取联合;出现在参数位置(逆变)时,会推导出交叉类型。所以同一个 infer 写在参数里和写在返回值里,得到的类型并不一样——这不是 bug,是类型系统为了保证调用安全必须这么做。

常见提取场景还有:提取数组元素、提取构造参数 ConstructorParameters、以及从模板字面量里分段提取(见下例的 AfterDash)。

位置决定变型的具体体现:返回值位置(协变)会把联合分别推导再合并;参数位置(逆变)会推导出交叉。所以同一个 infer 写在参数里和写在返回值里结果不同:

type ReturnOf<F> = F extends (...args: any[]) => infer R ? R : never
type ParamOf<F> = F extends (...args: infer P) => any ? P : never

type F = (x: string) => number
type R = ReturnOf<F> // number(取返回值,协变)
type P = ParamOf<F> // [string](参数位置,单层参数元组)
// 从模板字面量里提取后半段
type AfterDash<T extends string> = T extends `${string}-${infer R}` ? R : T
type Ver = AfterDash<'v1-2026-01-01'> // '2026-01-01'

2.2 同一句骨架,换抓取位置​

把 infer 的常见用法记成一张清单会更顺手:提取函数返回值(ReturnType)、提取参数(Parameters)、提取构造参数(ConstructorParameters)、解包 Promise(Awaited)、提取数组元素(T extends (infer E)[] ? E : never)、提取 Map 的键与值(T extends Map<infer K, infer V> ? ...)。它们的骨架都是同一句「在 extends 后面的某个位置放 infer X」,区别只在你要抓的是哪个位置。遇到新的提取需求,先想清楚「答案藏在哪个位置」,再决定 infer 写在哪。

2.3 递归展开元组与深层结构​

条件类型 + 递归能实现 DeepPartial / DeepReadonly。但递归类型必须加深度上限,否则超过一定层数会触发「类型实例化过深」的报错。做法是带一个计数元组,每递归一层消耗一个元素,到 0 就停止。

// 下标即「当前深度」,取值是「再往下允许的深度」。0 映射到 never,递归在这里停住。
type Prev = [never, 0, 1, 2, 3, 4, 5]

type DeepPartial<T, D extends number = 5> = D extends 0
? T
: T extends object
? { [K in keyof T]?: DeepPartial<T[K], Prev[D]> }
: T

2.4 不设上限会实例化过深​

不设上限的递归类型迟早会炸——不是「写得深才强大」,而是「深了就必须收得住」。

3. 裸类型参数的自动分发​

裸类型参数参与条件类型时会分发:联合类型的每一项分别计算再合并。[T] extends [U] 用元组包住,可以关闭分发,把联合当作整体判断。两种写法表达的是不同意图,不是谁对谁错。

type IsString<T> = T extends string ? true : false
type A = IsString<string | number> // boolean(分发后 true | false 合并)
type B = [string | number] extends [string] ? true : false // false(整体判断)

4. 类型也要讲可读性​

类型体操要克制:能直接声明就别推导,可读性优先。复杂类型要写用例测试(expectTypeOf 或类型断言),否则重构时没人知道它是否还成立——类型写错了不会在运行时爆,只会在某次调用处给你一个看不懂的报错。

4.1 编译时间和报错信息​

类型体操的成本也要算清楚:复杂条件类型会拖慢 tsc 的编译,尤其在 CI 里大仓全量类型检查时;过度嵌套还会让报错变成「类型实例化过深」或一长串看不懂的推导。经验法:能用内置工具类型(ReturnType、Parameters、Awaited)或简单映射解决的,绝不手搓三段递归;要手搓时把深度上限写死,并把中间结果拆成命名别名,方便 hover 与测试。

infer 的推导规则写在 TypeScript 手册:Conditional Types,里面的分配行为一节不看很容易翻车。