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

一、先把条件类型看成“类型层面的 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>
这里的匹配过程可以理解为:
- 判断
T是否是一个函数; - 如果是,就把返回值位置的类型推断为
R; - 返回
R; - 如果不是函数,返回
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。因为 string 和 number 被分别判断了。
要关闭分发,可以把两边包进元组:
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>
得到的结果会递归地把 server、ports、feature 以及它们内部的属性设为只读。
递归类型适合描述“结构规则”,但不适合无限制地展开类型。结构过于复杂、递归层级过深,可能让类型检查变慢,甚至触发 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
它的处理方式是:
- 判断路径中是否存在点号;
- 如果存在,用
infer拆出第一段Head和剩余部分Tail; - 判断
Head是否是当前对象的键; - 如果是,就进入下一层递归;
- 如果没有点号,直接取最后一层属性;
- 找不到路径时返回
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 处理,使用它的成本可能超过收益。公共类型不仅要能通过编译,还要让团队成员看得懂、改得动、出错时能定位。
一个实用判断标准是:如果类型工具不能让调用者获得更准确的参数约束或返回值推导,就应该考虑简化,甚至改用重载、联合类型或显式类型参数。
八、读懂和编写工具类型的检查顺序
阅读一个陌生的条件类型时,可以按下面的顺序拆解:
- 找到最外层的
T extends U ? X : Y; - 判断
T是否是裸类型参数; - 如果传入联合类型,确认是否会发生分发;
- 检查
infer提取的是参数、返回值、属性还是数组元素; - 查看失败分支返回的是
never、原类型还是默认类型; - 如果出现递归,确认它每次是否都在缩小结构;
- 最后再用几个具体输入手动推演结果。
自己编写工具类型时,也可以先从一个具体问题开始,而不是从“我要写一个通用类型”开始。例如先解决“从事件联合里提取指定事件负载”,再考虑是否需要支持更广泛的结构。
条件类型真正带来的收益,是把原本需要人工维护的类型对应关系交给编译器检查。但它只适合表达稳定、可推导的结构关系。遇到运行时信息、少量固定分支或难以解释的复杂类型时,重载和联合类型往往更可靠。

发布者:jacky,转转请注明出处:https://kubiyun.com/archives/4536
评论列表(5条)
那个用元组包裹来关闭分发的技巧太实用了,之前一直被 boolean 结果搞懵。
递归路径解析那段真有用,正好项目里需要这个
函数先排除这步,不说还真容易写反
类型推导再准,也得记得运行时校验
复杂条件类型写的时候爽,改的时候头疼