Back

深入理解 TypeScript 中的 infer

深入理解 TypeScript 中的 infer

infer 在条件类型的 extends 子句中声明一个类型变量,捕获匹配到的子类型,以便在 true 分支中使用——它在该位置之外没有任何意义,在其他任何地方使用都会导致编译错误。如果你曾打开过某个库的 .d.ts 文件,或者在同事的 PR 中看到过类似 T extends (...args: any[]) => infer R ? R : any 这样的代码,那么这行代码正是在使用 infer 从类型中提取某个部分——就像解构从对象中提取值一样。

核心要点

  • infer 只能合法地出现在条件类型的 extends 子句内部,它所引入的变量仅在 true 分支中有效——在 false 分支中引用它会导致编译器报错。
  • inferTypeScript 2.8 中引入;模板字面量中的 infer4.1 版本加入,Awaited<T>4.5 版本加入,infer X extends Constraint4.7 版本加入。
  • 当类型参数是裸类型变量且传入联合类型时,条件类型会对每个成员分别进行分发,因此 infer 会对每个成员单独解析,结果汇总为一个联合类型——这是一个常见的隐式类型拓宽 bug。
  • 自 TypeScript 4.5 起,Awaited<T> 是内置的 Promise 链展开工具;在现代 TypeScript 中没有必要手动重新实现 UnwrapPromise
  • 同一个 infer 变量的多个候选类型,在协变位置会产生联合类型,在逆变位置会产生交叉类型。

条件类型:infer 的生存基础

条件类型使用 T extends X ? A : B 的形式,根据一个类型是否可赋值给另一个类型来选择两种类型之一。它的工作方式类似于三元运算符,但发生在类型层面:如果 T 可赋值给 X,则类型解析为 A,否则解析为 BTypeScript 手册中关于条件类型的章节是主要参考资料。

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

容易让人困惑的行为是分发性。当被检查的类型是类型参数——即直接使用 T,而非将其包裹在其他类型中——并且传入联合类型时,TypeScript 会对每个联合成员分别进行条件分发,并将结果汇总为一个新的联合类型。将参数包裹在元组中([T] extends [X])可以关闭分发行为,将联合类型作为一个整体进行检查。这一区别在分发式条件类型章节中有详细说明。

type Distributed<T> = T extends string ? T[] : never;
type R1 = Distributed<string | number>; // string[](number 成员解析为 never 并被消除)

type NonDistributed<T> = [T] extends [string] ? T[] : never;
type R2 = NonDistributed<string | number>; // never —— 作为整体进行检查

请牢记这条规则:它是最常见的 infer 陷阱的根源,将在下文的注意事项部分详细介绍。

infer 模式的结构解析

通过 infer 引入的类型变量仅在条件类型的 true 分支中有效;在 false 分支中引用它会导致 TypeScript 编译器报错。 inferextends 模式内部引入变量,并将其绑定到条件求值时该位置所匹配的内容。这使得 infer 的行为有点像解构——但作用于类型:你编写一个反映预期结构的模式,为想要提取的部分命名,TypeScript 会自动填充它们。

type ElementType<T> = T extends (infer U)[] ? U : T;

type A = ElementType<string[]>; // string
type B = ElementType<number>;   // number

模式 (infer U)[] 的含义是”某种元素类型的数组——将该元素类型称为 U”。当 Tstring[] 时,U 绑定为 string。当匹配失败时,执行 false 分支,此时 U 不再可用:

type Bad<T> = T extends (infer U)[] ? U[] : U;
//                                          ^ 错误:找不到名称 'U'。

单个模式可以包含多个 infer 变量,每个变量绑定到各自的位置:

type SplitFn<T> = T extends (arg: infer A) => infer R ? [A, R] : never;

type S = SplitFn<(x: number) => string>; // [number, string]

这里 infer A 捕获参数类型,infer R 在同一次操作中捕获返回类型。这正是内置工具类型所采用的技术。

标准库中的 infer:规范化的类型提取器

TypeScript 标准库使用 infer 实现了其类型提取工具。每个工具都遵循相同的结构:匹配一个模式,绑定感兴趣的位置,在 true 分支中返回它。以下定义均来自 lib/es5.d.ts

函数返回类型 —— ReturnType

ReturnType<T> 通过匹配函数形状并将返回位置绑定到 infer R 来提取函数类型的返回类型。标准库定义将 T 约束为可调用类型。

type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

type A = ReturnType<() => string>;        // string
type B = ReturnType<() => { id: number }>; // { id: number }

注意泛型约束 T extends (...args: any) => any——这是实际标准库定义的一部分,并非可选的装饰。

要点: ReturnTypeinfer 最简洁的示例——对函数进行模式匹配,捕获返回槽位。

函数参数元组 —— Parameters

Parameters<T> 通过将剩余参数位置绑定到 infer P,将函数的参数列表提取为元组。

type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

type Args = Parameters<(a: string, b: number) => void>; // [a: string, b: number]

要点: 由于参数被建模为元组类型,对 ...args 使用 infer P 可以获取完整的参数列表,并保留标签和可选性。

Promise 解析 —— 使用 Awaited<T>,而非手写展开器

自 TypeScript 4.5 起,内置的 Awaited<T> 可以递归展开 Promise 链并处理 PromiseLike——在现代 TypeScript 中没有必要手动重新实现 UnwrapPromise。许多较早的教程会自定义 T extends Promise<infer U> ? U : T,但这种单层版本无法展开嵌套的 Promise,也无法处理 .then 式对象。Awaited 两者都能处理,详见 TypeScript 4.5 发布说明

type A = Awaited<Promise<string>>;            // string
type B = Awaited<Promise<Promise<number>>>;   // number —— 递归展开
type C = Awaited<boolean | Promise<number>>;  // number | boolean

要点: 直接使用 Awaited<T>。仅在学习练习时才编写自己的基于 infer 的 Promise 展开器。

数组元素类型

提取数组的元素类型是最小的实用 infer 模式:匹配 (infer U)[] 并返回 U

type ElementOf<T> = T extends readonly (infer U)[] ? U : never;

type A = ElementOf<number[]>;          // number
type B = ElementOf<readonly string[]>; // string

要点: 在模式中加入 readonly,使该工具类型同时适用于可变数组和只读数组。

元组的首元素、尾部和剩余元素

元组支持带有剩余元素的位置化 infer 模式,可以提取首元素、尾部或最后一个元素。这些模式与 JavaScript 数组解构相对应。

type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
type Tail<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;

type H = Head<[1, 2, 3]>; // 1
type R = Tail<[1, 2, 3]>; // [2, 3]
type L = Last<[1, 2, 3]>; // 3

要点: [infer Head, ...infer Rest][...any[], infer Last] 是递归元组操作的基础构建块——相同的模式驱动着类型级路由器和表单状态库。

递归推断 —— Flatten

infer 结合递归可以让类型逐层剥离任意深度的嵌套结构。Flatten 递归遍历嵌套数组,直到遇到非数组类型为止。

type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;

type A = Flatten<number[][][]>; // number
type B = Flatten<string>;       // string

每次迭代将 U 绑定为元素类型,并将其重新传入 Flatten。当 T 不再是数组时,false 分支将其原样返回。

要点: 递归 infer 是折叠嵌套容器的方式;相同的结构可以展开深层嵌套的 Promise 或对象类型。

模板字面量推断 —— 在类型层面解析字符串

模板字面量类型在其替换位置支持 infer。下面的 ExtractId<T> 示例匹配字符串模式并将可变部分绑定到 Id,从而无需运行时解析器即可实现类型化路由参数提取——这是自 TypeScript 4.1 起可用的模式。

type ExtractId<T> = T extends `/users/${infer Id}` ? Id : never;

type A = ExtractId<"/users/42">;   // "42"
type B = ExtractId<"/posts/42">;   // never

你可以跨多个路径段链式使用 infer,将完整路径解析为各个部分:

type RouteParams<T> =
  T extends `${infer _Start}/:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : T extends `${infer _Start}/:${infer Param}`
      ? Param
      : never;

type P = RouteParams<"/users/:userId/posts/:postId">; // "userId" | "postId"

这是路径解析器的类型层面等价实现,也是各框架和库中类型化路由的基础——这些库从路由字符串派生参数对象。

要点: 模板字面量 infer 可以将字符串形状的类型转换为结构化数据,且零运行时开销——适用于路由参数、CSS 属性联合类型以及解析带品牌标记的字符串格式。

约束推断:infer X extends Constraint(TS 4.7)

TypeScript 4.7 新增了 infer X extends Constraint,允许条件类型在匹配时对推断变量施加约束——在单个步骤中同时完成推断和约束检查。详见 TypeScript 4.7 发布说明。在 4.7 之前,你必须先进行推断,然后再添加第二个嵌套条件来收窄结果。

// 4.7 之前:先推断,再通过第二个条件进行检查
type FirstStringOld<T> =
  T extends [infer H, ...any[]]
    ? H extends string ? H : never
    : never;

// 4.7+:在推断时直接施加约束
type FirstString<T> =
  T extends [infer H extends string, ...any[]] ? H : never;

type A = FirstString<["hi", 1, 2]>; // "hi"
type B = FirstString<[1, 2, 3]>;    // never

TypeScript 4.8 后续改进了对 numberbigintboolean 等原始类型的约束推断,使编译器在匹配模板字符串模式时能够保留更精确的字面量类型。这建立在 TypeScript 4.7 引入的约束 infer 语法基础之上,但作为独立的增强功能交付。

要点: 当你希望在单个表达式中同时获得捕获的类型和对其形状的保证时,使用 infer X extends C——它取代了旧有的”先推断再嵌套”模式。

三个会悄然破坏推断的陷阱

悄然破坏 infer 的三个陷阱分别是:联合类型分发导致结果隐式拓宽、方差差异使多个 infer 候选类型在联合类型与交叉类型之间切换,以及过于具体的模式落入 false 分支。infer 的失败通常是无声的——类型仍然可以编译,只是比你预期的更宽或更窄。

1. 分发性悄然拓宽结果

当类型参数 T 是裸类型变量且传入联合类型时,TypeScript 会对每个成员分别进行条件分发——这意味着 infer R 会对每个联合成员单独解析,结果汇总为一个联合类型,可能在你不知情的情况下将推断类型拓宽超出预期。

type Unwrap<T> = T extends Promise<infer U> ? U : T;

// 看起来返回单一类型;实际上会进行分发
type R = Unwrap<Promise<string> | Promise<number>>; // string | number

如果你希望拒绝混合联合类型而非将其合并,可以通过包裹参数来禁用分发:

type UnwrapStrict<T> = [T] extends [Promise<infer U>] ? U : T;

要点:T 加联合类型意味着逐成员求值。当需要将联合类型作为整体处理时,用 [T] 包裹。

2. 协变与逆变位置会改变结果

同一个 infer 变量的多个候选类型,在协变位置(如返回类型)产生联合类型,在逆变位置(如函数参数类型)产生交叉类型。对于重载函数,推断使用最后一个签名。这一行为在 TypeScript 2.8 发布说明中有所描述。

// 协变(返回位置):联合类型
type Co<T> = T extends { a: () => infer U; b: () => infer U } ? U : never;
type C = Co<{ a: () => string; b: () => number }>; // string | number

// 逆变(参数位置):交叉类型
type Contra<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never;
type D = Contra<{ a: (x: string) => void; b: (x: number) => void }>; // string & number

要点: 在多个位置复用同一个 infer 名称是有意为之且功能强大的,但结果是联合类型还是交叉类型取决于这些位置的方差。不要想当然地认为结果一定是联合类型。

3. 无法绑定的模式会落入 false 分支

infer 只在候选类型与模式在结构上匹配时才会绑定。如果形状不匹配,条件类型会走 false 分支,推断变量永远不会解析——因此过于具体的模式会悄然返回你的回退类型,而非你期望的值。

type FirstArg<T> = T extends (a: infer A, b: infer B) => any ? A : never;

type X = FirstArg<string>; // never —— string 不匹配函数类型

在这个例子中,FirstArg 期望一个函数类型。传入 string 不符合该模式,因此条件类型解析为 false 分支并返回 never

要点: 让模式尽可能宽松以适应匹配需求。使用 ...args: infer P[infer H, ...any[]] 而非固定参数数量的形状,除非你明确希望拒绝其他参数数量。

在实际项目中遇到 infer 的场景

在应用代码中,你很少需要自己编写 infer——你更多是在库的类型定义中遇到它,例如 React 的 ComponentProps、Express 的 RequestHandler、Redux Toolkit 的 PayloadAction 以及 RTK Query 的端点类型,然后使用这些工具类型的结果。识别 T extends Wrapper<infer X> ? X : Fallback 这一模式,可以让你读懂这些定义,而不是将它们视为黑盒。

React 的 ComponentProps @types/react 类型定义使用 infer 来定义 ComponentProps,从组件类型中提取其 props,并在宿主元素和组件构造函数之间进行分支。阅读它,你会看到与 ReturnType 相同的 T extends ... infer P ... ? P : ... 结构,只是应用于 React 的元素类型。

Express 请求处理器。 Express 的 @types/expressRequestHandler 建模为带有路由参数、响应体、请求体和查询参数的泛型。从路径字符串派生类型化路由处理器的库,将这些泛型与模板字面量 infer 结合,提取 :param 路径段——前面展示的 RouteParams 模式正是这一机制的核心。

Redux Toolkit 的 PayloadAction Redux Toolkit 中的 PayloadAction 是一个适合用 infer 提取的实用类型,尽管其自身定义依赖于泛型和条件类型而非 infer——这提醒我们,infer读取库类型的方式,不一定是库本身的编写方式。你通常会编写 type Payload<A> = A extends PayloadAction<infer P> ? P : never 来从 action 中还原 reducer 的 payload 类型。

import type { PayloadAction } from "@reduxjs/toolkit";

type PayloadOf<A> = A extends PayloadAction<infer P> ? P : never;

type P = PayloadOf<PayloadAction<{ id: string }>>; // { id: string }

RTK Query 端点。 RTK Query(Redux Toolkit 的一部分)暴露了生成的端点类型,其中查询类型和结果类型可以通过基于 infer 的工具类型来还原。一旦你识别了这一模式,使用绑定结果位置的条件类型来提取端点的结果类型便是日常操作。

总结

infer 是一个只有一条规则的特性:它在条件类型模式中为某个位置命名,并绑定该位置匹配到的内容,且只能在 true 分支中使用。一旦你将 T extends Pattern<infer X> ? X : Fallback 理解为类型层面的解构,每个内置工具类型和库的类型定义都是这一单一操作的变体。下次你在 .d.ts 文件中遇到 infer 时,追踪这个模式:找到变量所在的位置,检查参数是裸类型还是被包裹的类型,并确认你正在阅读的是 true 分支。从将代码库中所有手写的 UnwrapPromise 替换为 Awaited<T> 开始,并在当前需要嵌套第二个条件来收窄结果的地方采用 infer X extends Constraint

常见问题

infer 通过在条件类型内部进行模式匹配,从现有类型中提取子类型;而映射类型则将类型的每个属性转换为新的形状。当你需要从某个结构中读取子类型时(例如函数的返回类型或数组的元素类型),使用 infer。当你需要遍历键并生成新的对象类型时,使用映射类型。它们解决的是相反的问题:infer 负责读取,映射类型负责改写。

不可以。通过 infer 引入的类型变量仅在条件类型的 true 分支中有效;在 false 分支中引用它会产生 TypeScript 编译器错误,例如“找不到名称”。该变量只在匹配成功后才存在,因此 false 分支没有可绑定的值。如果你在两种结果中都需要某个值,可以在 true 分支中使用 infer,并在 false 分支中提供一个无关的回退类型。

这是由于分发性导致的。当类型参数是裸类型变量且传入联合类型时,TypeScript 会对每个成员分别进行条件分发,因此 infer 会对每个联合成员单独解析,结果汇总为一个新的联合类型。这可能在你不知情的情况下将结果拓宽超出预期。要禁用分发并将联合类型作为整体进行检查,可以将参数包裹在元组中,例如 [T] extends [Promise<infer U>] ? U : T。

不需要,在现代 TypeScript 中不必这样做。自 TypeScript 4.5 起,内置的 Awaited<T> 可以递归展开 Promise 链并处理 PromiseLike 的 thenable 对象。手写的 T extends Promise<infer U> ? U : T 只能展开一层,且忽略 thenable 对象,因此在嵌套 Promise 上会失效。在应用代码中直接使用 Awaited<T>,仅在学习练习时才编写自己的基于 infer 的展开器。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay