TypeScript 条件类型实用指南:从类型守卫到递归类型推断

AI智能摘要
TypeScript 条件类型通过 extends 按可分配性进行类型层面的分支判断,并可借助 infer 从函数、数组等结构中提取类型。文章还解释联合类型的分发机制、Extract 与 Exclude 的实现思路,以及用元组阻止分发的方法,区分了编译期推导与运行时类型守卫的职责。
— 此摘要由AI分析文章内容生成,仅供参考。

条件类型TypeScript 类型系统里最适合“根据输入类型做分支判断”的工具。它常见于 ReturnTypeExtractExclude 等内置工具类型,也经常出现在框架的事件、配置和 API 类型定义中。真正掌握它,不是记住几个工具类型,而是理解三个问题:extends 到底在判断什么、联合类型为什么会自动拆分,以及递归和 infer 什么时候能把复杂结构推导出来。

TypeScript 条件类型代码与类型分支示意

一、先把条件类型看成“类型层面的 if”

条件类型的基本形式是:

T extends U ? X : Y

它表达的是:如果类型 T 可以赋值给类型 U,结果就是 X;否则结果就是 Y

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

type A = IsString<string>  // true
type B = IsString<number>  // false

这里的 extends 不是类继承,而是可分配性判断。换句话说,重点不是“T 是否精确等于 U”,而是“类型为 T 的值能不能安全地放到类型为 U 的位置”。

type HasId<T> = T extends { id: string } ? true : false

type A = HasId<{ id: string; name: string }> // true
type B = HasId<{ name: string }>             // false

第一个对象虽然比 { id: string } 多了一个 name 属性,但它仍然可以赋值给只要求 id 的对象类型,所以判断结果是 true

条件类型和运行时类型守卫不是一回事

条件类型只在编译阶段计算,不会生成运行时判断。下面这个类型工具不会检查对象是否真的有 id

type IdType<T> = T extends { id: infer I } ? I : never

它只是在已知类型结构的前提下提取 id 的类型:

type User = {
  id: number
  name: string
}

type UserId = IdType<User> // number

如果需要在运行时判断,就仍然要使用类型守卫:

function hasId(value: unknown): value is { id: string } {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value
  )
}

条件类型负责“根据类型推导结果”,类型守卫负责“根据运行时值缩小类型”。两者可以配合,但不能互相替代。

infer 用来提取条件中的局部类型

infer 只能出现在条件类型的判断分支中,它表示:如果模式匹配成功,就把某一部分类型命名出来。

例如提取函数返回值:

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

type Result = MyReturnType<(id: string) => Promise<number>>
// Promise<number>

这里的匹配过程可以理解为:

  1. 判断 T 是否是一个函数;
  2. 如果是,就把返回值位置的类型推断R
  3. 返回 R
  4. 如果不是函数,返回 never

同样的思路也可以提取数组元素:

type ElementType<T> =
  T extends readonly (infer E)[] ? E : never

type Item = ElementType<readonly { id: string }[]>
// { id: string }

这种“匹配结构并提取局部类型”的写法,是阅读工具类型源码时最常见的模式。

二、联合类型的分发行为

条件类型有一个容易忽略的特性:当被判断的类型参数是裸类型参数,并且传入的是联合类型时,条件类型会对联合成员分别计算。

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

type Result = ToArray<string | number>
// string[] | number[]

它并不会得到:

(string | number)[]

而是先拆开计算:

ToArray<string> | ToArray<number>

也就是:

string[] | number[]

这种行为称为条件类型的分发。很多工具类型正是利用了这个特性。

用分发筛选联合成员

type OnlyString<T> =
  T extends string ? T : never

type Result = OnlyString<string | number | boolean>
// string

计算过程大致是:

OnlyString<string>  // string
OnlyString<number>  // never
OnlyString<boolean> // never

最终结果是:

string | never | never

never 会从联合类型中消失,因此最后只剩下 string

这就是 Extract 的核心思路之一:

type MyExtract<T, U> =
  T extends U ? T : never

type Result = MyExtract<
  "create" | "update" | "delete",
  "create" | "delete"
>
// "create" | "delete"

相反,如果希望排除某些类型,可以把符合条件的成员替换成 never

type MyExclude<T, U> =
  T extends U ? never : T

type Result = MyExclude<
  "create" | "update" | "delete",
  "update"
>
// "create" | "delete"

不想分发时,用元组包住类型参数

分发并不总是你想要的结果。比如,你想判断整个联合类型是否都可以赋值给某个类型,而不是逐个成员判断:

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

type Result = IsString<string | number>
// boolean

这里的结果不是 false,而是 true | false,简化后显示为 boolean。因为 stringnumber 被分别判断了。

要关闭分发,可以把两边包进元组:

type IsEntirelyString<T> =
  [T] extends [string] ? true : false

type Result = IsEntirelyString<string | number>
// false

此时判断对象变成了整体的 [string | number],不会再把联合拆开。

这个技巧在以下场景特别有用:

  • 判断一个联合是否整体满足约束;
  • 判断某个类型是否为 never
  • 编写不希望逐个联合成员计算的工具类型;
  • 避免条件类型的结果被拆成多个分支。

判断 never 时尤其要注意:

type IsNever<T> =
  [T] extends [never] ? true : false

type A = IsNever<never> // true
type B = IsNever<string> // false

如果写成 T extends never,由于 never 的特殊分发行为,结果可能不是预期的布尔值。元组包裹可以让判断稳定下来。

三、递归条件类型:处理嵌套结构

当类型结构有层级关系时,条件类型可以调用自己,逐层处理嵌套数据。常见的递归类型包括深度只读、路径解析、嵌套数组展开和树形结构转换。

例如,一个简化版的深度只读类型:

type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T
    : T extends readonly unknown[]
      ? ReadonlyArray<DeepReadonly<T[number]>>
      : T extends object
        ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
        : T

它的判断顺序很重要。

函数也是对象的一种,如果先判断 T extends object,函数可能被映射成一组只读属性,导致原本的调用能力丢失。因此通常要先排除函数,再处理数组,最后处理普通对象。

type Config = {
  server: {
    host: string
    ports: number[]
  }
  feature: {
    enabled: boolean
  }
}

type ReadonlyConfig = DeepReadonly<Config>

得到的结果会递归地把 serverportsfeature 以及它们内部的属性设为只读。

递归类型适合描述“结构规则”,但不适合无限制地展开类型。结构过于复杂、递归层级过深,可能让类型检查变慢,甚至触发 TypeScript 的实例化深度限制。因此,递归工具应尽量保持目标明确,不要把多个复杂转换无条件叠加。

四、用递归和 infer 解析嵌套配置路径

一个更实用的场景是:配置对象是嵌套的,但调用者希望通过字符串路径访问它,同时让返回值能够保持准确类型。

假设有这样的配置:

type AppConfig = {
  server: {
    host: string
    port: number
  }
  feature: {
    darkMode: {
      enabled: boolean
    }
  }
}

可以用模板字面量类型拆分路径:

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

它的处理方式是:

  1. 判断路径中是否存在点号;
  2. 如果存在,用 infer 拆出第一段 Head 和剩余部分 Tail
  3. 判断 Head 是否是当前对象的键;
  4. 如果是,就进入下一层递归;
  5. 如果没有点号,直接取最后一层属性;
  6. 找不到路径时返回 never
type Host = PathValue<AppConfig, "server.host">
// string

type Port = PathValue<AppConfig, "server.port">
// number

type DarkModeEnabled =
  PathValue<AppConfig, "feature.darkMode.enabled">
// boolean

type Missing = PathValue<AppConfig, "server.timeout">
// never

这个类型工具的价值不在于支持任意字符串,而在于当路径是字面量时,能够让类型系统跟着路径逐层推导。

可以进一步把它用于函数签名:

declare function getConfigValue<P extends string>(
  path: P
): PathValue<AppConfig, P>

调用时:

const host = getConfigValue("server.host")
// 推导为 string

const enabled = getConfigValue("feature.darkMode.enabled")
// 推导为 boolean

不过这里要区分“类型声明能否表达”与“运行时实现是否安全”。真实实现仍然需要按点号切分字符串、逐层访问对象,并处理路径不存在的情况。类型工具不能替代运行时校验。

五、事件类型表中的递归与分发

事件系统是条件类型很常见的应用场景。与其手写一个宽泛的事件联合,不如先定义事件名和负载的对应关系:

type EventMap = {
  userCreated: {
    id: string
  }
  orderPaid: {
    orderId: string
    amount: number
  }
  themeChanged: {
    dark: boolean
  }
}

可以通过映射类型把它转换成事件联合:

type EventUnion<M> = {
  [K in keyof M & string]: {
    type: K
    payload: M[K]
  }
}[keyof M & string]

EventUnion<EventMap> 等价于:

type Events =
  | {
      type: "userCreated"
      payload: {
        id: string
      }
    }
  | {
      type: "orderPaid"
      payload: {
        orderId: string
        amount: number
      }
    }
  | {
      type: "themeChanged"
      payload: {
        dark: boolean
      }
    }

如果还需要根据事件名提取负载,可以使用条件类型:

type PayloadOf<E, K> =
  E extends { type: K; payload: infer P }
    ? P
    : never

type OrderPaidPayload =
  PayloadOf<EventUnion<EventMap>, "orderPaid">

因为 E 是裸类型参数,传入事件联合后会发生分发。只有符合 type: "orderPaid" 的成员能够匹配,最后得到:

type OrderPaidPayload = {
  orderId: string
  amount: number
}

这种写法适合事件总线、消息分发器和状态更新系统,因为事件名与负载之间的关联不会被一个宽泛的联合类型抹掉。

如果直接写成:

type EventPayload = EventUnion<EventMap>["payload"]

得到的会是所有负载的联合:

| { id: string }
| { orderId: string; amount: number }
| { dark: boolean }

它适合“可以处理任意事件”的场景,但不适合需要根据事件名精准约束参数的 API。什么时候保留整体联合,什么时候按键提取,是设计类型工具时必须做出的选择。

六、什么时候应该使用条件类型

条件类型最适合表达“类型之间存在稳定的结构判断”。下面几类需求通常值得使用:

1. 从已知结构中提取类型

例如提取函数返回值、Promise 内部值、数组元素、对象某个字段,或者从事件联合中找到指定事件的负载。

type UnwrapPromise<T> =
  T extends Promise<infer R> ? R : T

type A = UnwrapPromise<Promise<string>>
// string

type B = UnwrapPromise<number>
// number

2. 对联合类型做筛选或转换

当目标是保留某些成员、排除某些成员,或者把每个联合成员映射成另一种类型时,分发行为非常有用。

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

3. 处理有明确终止条件的递归结构

嵌套配置、路径、数组元素、树形节点等,都可以用递归条件类型处理。但要保证递归能够逐步靠近终止条件,并且不会把整个类型系统变成一套难以维护的编程语言。

4. 需要让调用者获得更精确的返回类型

如果函数参数中的字面量、键名或路径能够决定返回值类型,条件类型可以把这种关联表达出来。它尤其适合库代码、公共 API 和内部基础设施类型。

七、什么时候应该退回重载或联合类型

条件类型并不是越复杂越好。以下情况使用重载通常更清晰。

运行时分支本身就是固定的几个场景

例如函数根据参数类型返回不同结果:

function parseValue(value: string): string[]
function parseValue(value: string[]): string[]
function parseValue(value: string | string[]) {
  return Array.isArray(value) ? value : value.split(",")
}

如果只有少量明确分支,重载能直接呈现调用方式,读者不必先展开一个复杂的条件类型。

返回值无法仅由静态类型决定

如果函数接收的是普通 string,而不是字符串字面量,类型系统无法知道它运行时代表哪个事件或路径。这时强行使用条件类型,可能只是让签名变长,却没有增加真实安全性。

declare function getValue(path: string): unknown

如果调用者传入的路径来自外部输入,返回 unknown 往往比假装能够精确推导更诚实。之后再通过校验或类型守卫缩小类型。

类型关系并不稳定,或者需要表达几种完整形态

联合类型适合描述“值可能是这些类型之一”:

type Result =
  | { ok: true; data: string }
  | { ok: false; error: Error }

这种结构已经能够表达业务状态,就不必为了抽象而再套一层条件类型。

工具类型已经难以解释和调试

如果一个条件类型同时包含多层分发、多个 infer、递归映射和特殊的 never 处理,使用它的成本可能超过收益。公共类型不仅要能通过编译,还要让团队成员看得懂、改得动、出错时能定位。

一个实用判断标准是:如果类型工具不能让调用者获得更准确的参数约束或返回值推导,就应该考虑简化,甚至改用重载、联合类型或显式类型参数。

八、读懂和编写工具类型的检查顺序

阅读一个陌生的条件类型时,可以按下面的顺序拆解:

  1. 找到最外层的 T extends U ? X : Y
  2. 判断 T 是否是裸类型参数;
  3. 如果传入联合类型,确认是否会发生分发;
  4. 检查 infer 提取的是参数、返回值、属性还是数组元素;
  5. 查看失败分支返回的是 never、原类型还是默认类型;
  6. 如果出现递归,确认它每次是否都在缩小结构;
  7. 最后再用几个具体输入手动推演结果。

自己编写工具类型时,也可以先从一个具体问题开始,而不是从“我要写一个通用类型”开始。例如先解决“从事件联合里提取指定事件负载”,再考虑是否需要支持更广泛的结构。

条件类型真正带来的收益,是把原本需要人工维护的类型对应关系交给编译器检查。但它只适合表达稳定、可推导的结构关系。遇到运行时信息、少量固定分支或难以解释的复杂类型时,重载和联合类型往往更可靠。

嵌套配置与事件类型的递归推断示意

发布者:jacky,转转请注明出处:https://kubiyun.com/archives/4536

(0)
jacky的头像jacky
上一篇 6天前
下一篇 6天前

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

评论列表(5条)

  • 话痨收割机的头像
    话痨收割机 2026-09-14 09:16

    那个用元组包裹来关闭分发的技巧太实用了,之前一直被 boolean 结果搞懵。

  • 影月游侠的头像
    影月游侠 2026-09-14 10:26

    递归路径解析那段真有用,正好项目里需要这个

  • 夜樱纷飞的头像
    夜樱纷飞 2026-09-14 10:46

    函数先排除这步,不说还真容易写反

  • 星夜独行者的头像
    星夜独行者 2026-09-14 11:01

    类型推导再准,也得记得运行时校验

  • 红尘侠女的头像
    红尘侠女 2026-09-14 11:11

    复杂条件类型写的时候爽,改的时候头疼