TypeScript 条件类型与 infer:从模式匹配到递归类型推导的实战路径

HTMLPAGE 团队
19 分钟阅读

条件类型和 infer 是 TypeScript 类型系统中最灵活也最容易被误用的机制。本文从实际代码场景出发,讲清条件类型的匹配规则、infer 的推断时机、递归类型推导的边界,以及它们如何帮助写出更精确的工具类型。

#TypeScript #条件类型 #infer #类型推导 #递归类型 #高级类型

条件类型和 infer 是 TypeScript 类型系统里最强大的两个机制,也是很多人文档看完但不会用的两个概念。文档告诉你 T extends U ? X : Y 是条件类型,infer R 可以提取类型变量,但实际看到 ReturnType<T> 的源码时还是懵的。

这不是理解能力的问题。条件类型的难点不在语法,而在它运行在一个和值空间完全不同的逻辑系统里。类型不是数据,不能计算,不存在运行时流程控制。条件类型的"条件"本质上是类型结构的模式匹配——它不看取值范围,只看结构形状。

条件类型的匹配规则:结构兼容,不是完全相等

条件类型 T extends U ? A : B 的判断标准,和函数签名里的 extends 约束其实是同一套规则:U 能不能收容 T。不是 T 和 U 长得一样,而是 T 的类型的值能不能赋值给 U。

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

type R1 = IsString<'hello'>  // true —— 字面量 'hello' 可以赋值给 string
type R2 = IsString<42>       // false —— number 不能赋值给 string
type R3 = IsString<string>   // true —— string extends string

看起来简单,但换成对象类型就很容易判断错:

type HasName<T> = T extends { name: string } ? 'yes' : 'no'

type R4 = HasName<{ name: 'alice' }>          // 'yes' —— 含 name 且兼容
type R5 = HasName<{ name: string; age: number }>  // 'yes' —— 多字段不影响
type R6 = HasName<{ age: number }>             // 'no' —— 缺 name

这里经常有人困惑:为什么 { name: string; age: number } 能匹配 { name: string }?因为条件类型看的是 U(右侧)能不能收容 T(左侧),不是反过来。对象类型的兼容性是结构子类型——多字段的结构可以被少字段的结构收容,只要被检查的字段满足要求。

把这个规则反过来,就是 extends 约束里的分配条件类型(distributive conditional types):

type ToArray<T> = T extends unknown ? T[] : never

type R7 = ToArray<string | number>
// string[] | number[] —— 不是 (string | number)[]

联合类型会被拆开分别匹配再合并,这就是分配律。关掉分配律的方法是用方括号包裹泛型参数:

type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never

type R8 = ToArrayNonDist<string | number>
// (string | number)[]

infer 的实质:在匹配位置声明类型变量

infer 不是什么黑魔法——它只是在条件类型的 extends 子句中声明一个待推导的类型变量,然后让 TypeScript 在匹配过程中自动填充它。

最简单的例子是从函数类型提取返回值类型:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never

type Fn = (x: number, y: string) => boolean
type R9 = MyReturnType<Fn>  // boolean

流程是这样的:

  1. 传入 (x: number, y: string) => boolean
  2. 检查 (x: number, y: string) => boolean extends (...args: any[]) => infer R
  3. (...args: any[]) 匹配 (x: number, y: string),剩余 => boolean
  4. infer R 被推导为 boolean
  5. 返回 boolean

infer 有一个容易踩的坑——它只在条件类型为 true 的分支里可用:

// 错误用法
type ExtractPromiseBad<T> = T extends Promise<infer U> ? U : never
type X = ExtractPromiseBad<Promise<string>>  // string —— 正确

// 但不代表 infer 可以出现在任意位置
type Wrong<T> = infer U extends T ? U : never  // 语法错误

infer 必须出现在 extends 子句的右侧,而且只能在条件为 true 时才能被使用。

递归类型推导:条件类型 + infer 的组合

真正体现 infer 威力的是递归类型推导。考虑一个场景:从嵌套对象中提取所有叶子路径的值的类型。

type DeepValue<T> = T extends Record<string, infer V>
  ? V extends Record<string, any>
    ? DeepValue<V>
    : V
  : T

type Obj = { a: { b: { c: string } } }
type R10 = DeepValue<Obj>  // string

递归类型推导需要特别注意终止条件。如果没有终止条件,TypeScript 会达到递归深度上限(通常是 50 层)然后报错:

type InfiniteDeep<T> = T extends Record<string, infer V>
  ? InfiniteDeep<V>
  : never
// 编译器可能报 "Type instantiation is excessively deep and possibly infinite"

实用模式 1:展开 Promise

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

type R11 = Unwrap<Promise<Promise<Promise<string>>>>
// string —— 递归展开所有层级

实用模式 2:提取函数参数类型

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

type Fn2 = (a: number, b: string, c: boolean) => void
type Params = MyParameters<Fn2>
// [number, string, boolean] —— 元组类型

注意这里 infer P 不是单个类型,而是参数元组。TypeScript 会把 ...args 的展开类型自动推断为元组。

实用模式 3:条件推断 + 模板字面量

type ExtractId<T extends string> =
  T extends `id-${infer Rest}` ? Rest : never

type R12 = ExtractId<'id-abc123'>  // 'abc123'
type R13 = ExtractId<'name-xyz'>   // never

实战场景:实现一个类型安全的路径提取器

下面用一个稍微复杂一点的例子,展示条件类型和 infer 的组合威力。假设你需要一个类型,能从深层嵌套对象中提取指定路径的值类型:

type PathValue<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? PathValue<T[K], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never

type Data = {
  user: {
    profile: {
      name: string
      age: number
    }
    settings: {
      theme: 'light' | 'dark'
    }
  }
}

type R14 = PathValue<Data, 'user.profile.name'>  // string
type R15 = PathValue<Data, 'user.settings.theme'>  // 'light' | 'dark'
type R16 = PathValue<Data, 'user.profile.email'>  // never —— 路径不存在

这个类型有两个递归分支:

  1. 如果路径包含 .,用模板字面量拆分出第一段和剩余路径
  2. 如果路径不包含 .,直接取 key

infer 的逆协变与逆变

当 infer 出现在不同位置时,它的赋值行为不同。这是很多人踩坑的地方:

// 协变位置(函数返回值)
type CoExtract<T> = T extends () => infer R ? R : never
type R17 = CoExtract<() => string | number>  // string | number

// 逆变位置(函数参数)
type ContraExtract<T> = T extends (x: infer P) => any ? P : never
type R18 = ContraExtract<(x: string | number) => void>
// string | number —— 看起来一样

但在泛型中,逆变位置的 infer 会有不同的行为:

type UnionToIntersection<T> =
  (T extends any ? (x: T) => void : never) extends (x: infer R) => void
    ? R
    : never

type R19 = UnionToIntersection<string | number>
// string & number —— 联合转交叉的经典实现

这个技巧利用了函数参数的逆变位置:当多个类型分布在同一个逆变位置上时,TypeScript 会尝试取交集。

递归类型推导的性能问题

递归条件类型不是免费的。每层递归都会增加类型实例化的深度。以下场景尤其需要注意:

// 逐个元素处理的递归(性能较差的写法)
type DeepReadonlyArray<T extends any[]> = {
  [K in keyof T]: T[K] extends object
    ? DeepReadonlyArray<T[K]>
    : T[K]
}

// 用映射类型减少递归深度(优化写法)
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends Record<string, any>
    ? DeepReadonly<T[K]>
    : T[K]
}

映射类型的单层操作不会增加递归深度,而递归条件类型每展开一层都计数。对于可能超过 30 层嵌套的结构,先评估是否真的需要这么深的递归。

总结:条件类型 + infer 的使用建议

场景使用方式注意事项
提取函数返回值类型(...args: any[]) => infer R简单直接,无限参数兼容
提取 Promise 内部类型Promise<infer T>递归展开需加终止条件
路径拆分模板字面量 + infer注意 keyof 配合
联合转交叉逆变位置 infer理解原理再使用
递归对象类型映射类型 + 条件评估深度,避免爆炸

条件类型的难点从来不在语法,而在于它遵循的是结构子类型规则,不是等值规则。写条件类型时,心里应该想着"这个形状能不能匹配那个形状",而不是"这两个值相不相等"。