本节目标:读完这一节,你能写出
T extends U ? X : Y形式的条件类型并预测它对联合类型的分发行为;能用infer从函数、数组、元组、Promise 中把内部类型「抠」出来;能手写Exclude、ReturnType、Parameters的实现;并且能解释 裸参数版本IsNeverDistributed<never>为什么返回never,以及包裹版本IsNever<never>为什么返回true。
9.3 条件类型与 infer
本节是第 9 章「高级类型」的收官。前面两节分别讲了「读取类型」(keyof / 索引访问)和「生成类型」(映射类型);这一节要补上最后一块拼图——让类型会做判断。
条件类型是类型系统里的 if,infer 则是类型系统里的「解构赋值」。二者结合,才让 TypeScript 具备了我们常说的「类型编程(type-level programming)」能力。标准库里那些看起来像黑箱的 ReturnType<T>、Awaited<T>,本质上都是这两样东西的组合。
条件类型:类型层面的 if/else
语法与三元表达式几乎一致,只是判断对象是类型:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // true
T extends U 在这里的含义是「T 是否可赋值给 U」,也就是 T 是不是 U 的子类型,而不是「T 是否等于 U」。这个区别很重要:
type IsAssignableToNumber<T> = T extends number ? "yes" : "no";
type D = IsAssignableToNumber<42>; // "yes"
type E = IsAssignableToNumber<42 | string>; // "yes" | "no" ← 注意这里
最后一行返回了联合,这就引出了下一节要讲的「分发」。
条件类型可以嵌套,实现多分支判断:
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends null ? "null" :
"object";
type N1 = TypeName<"a">; // "string"
type N2 = TypeName<() => void>; // "object"
分布式条件类型
当条件类型作用在一个裸类型参数上,且传入的是联合类型时,它会自动对联合的每个成员分别求值,再把结果合并成联合。这叫「分布式条件类型(distributive conditional types)」。
type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>;
// 等价于 ToArray<string> | ToArray<number>
// 即 string[] | number[],而不是 (string | number)[]
如果期望的是 (string | number)[],就需要阻止分发:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type R2 = ToArrayNonDist<string | number>;
// (string | number)[]
用方括号把 T 包起来,它就不再是一个裸类型参数,分发随之关闭。这个技巧在写 IsNever 这类判断时是必需的:
// ❌ 错误写法
type IsNeverDistributed<T> = T extends never ? true : false;
type X = IsNeverDistributed<never>; // never —— 而不是 true!
// ✅ 正确写法
type IsNever<T> = [T] extends [never] ? true : false;
type Y = IsNever<never>; // true
为什么 IsNeverDistributed<never> 是 never?因为 never 是一个「空联合」,分发时没有任何成员可供展开,结果自然还是 never。这是新手最容易困惑的一个点,记住「never 分发后仍是 never」即可。
顺带一个实用技巧:判断 any。因为 any 与任何类型的交叉都是 any,可以这样写:
type IsAny<T> = 0 extends 1 & T ? true : false;
type P = IsAny<any>; // true
type Q = IsAny<string>; // false
infer:在类型里「解构」
infer 只能出现在条件类型的 extends 右侧。它的作用是声明一个占位符类型变量,让编译器在匹配时把对应位置的类型「填」进去:
type ElementType<T> = T extends (infer U)[] ? U : never;
type E1 = ElementType<string[]>; // string
type E2 = ElementType<number[][]>; // number[]
type E3 = ElementType<number>; // never(匹配失败,走 false 分支)
可以把它理解为一次类型层面的模式匹配:(infer U)[] 是「模式」,string[] 是「被匹配的值」,U 就是解出来的绑定。
从函数签名中提取返回值,这是最常见的用法:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function fetchUser(): { id: number; name: string } {
return { id: 1, name: "Ada" };
}
type User = MyReturnType<typeof fetchUser>;
// { id: number; name: string }
注意参数位置写的是 (...args: any[]) => infer R 而不是 () => infer R。原因在于函数参数是逆变的:(a: string) => number 不能赋值给 () => number,用后者做模式会匹配失败,白白落到 never 分支。写 ...args: any[] 是稳妥的习惯。
元组与变长位置上的 infer
infer 可以配合剩余元素语法,从元组的两端取值:
type Head<T> = T extends [infer F, ...unknown[]] ? F : never;
type Tail<T> = T extends [unknown, ...infer R] ? R : never;
type Last<T> = T extends [...unknown[], infer L] ? L : never;
type H = Head<[string, number, boolean]>; // string
type T1 = Tail<[string, number, boolean]>; // [number, boolean]
type L = Last<[string, number, boolean]>; // boolean
同一个模式里也可以声明多个 infer,位置一一对应:
type Swap<Pair> = Pair extends [infer A, infer B] ? [B, A] : never;
type Swapped = Swap<[string, number]>; // [number, string]
TypeScript 4.7 起,infer 还支持直接带约束,省去一层嵌套判断:
type FirstNumber<T> = T extends [infer F extends number, ...unknown[]] ? F : never;
type FN1 = FirstNumber<[42, "x"]>; // 42
type FN2 = FirstNumber<["x", 42]>; // never
递归解包:手写 Awaited
infer 与条件类型可以递归调用自身,这让「层层剥开」成为可能。标准库里的 Awaited<T> 就是这样实现的:
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
type R1 = MyAwaited<Promise<string>>; // string
type R2 = MyAwaited<Promise<Promise<number>>>; // number
type R3 = MyAwaited<{ id: number }>; // { id: number }(非 Promise 原样返回)
递归类型很容易写「深」,一旦层次过多,编译器会抛出:
Type instantiation is excessively deep and possibly infinite. ts(2589)
处理手法(尾递归累加器、深度上限)属于 10.3 递归类型与类型性能治理 的主题。
手写内置工具类型
把本节学到的两样东西组合起来,标准库里的「黑箱」就都能自己写出来了:
| 工具类型 | 实现 |
|---|---|
Exclude<T, U> | T extends U ? never : T |
Extract<T, U> | T extends U ? T : never |
NonNullable<T> | T extends null | undefined ? never : T |
ReturnType<T> | T extends (...args: any[]) => infer R ? R : any |
Parameters<T> | T extends (...args: infer P) => any ? P : never |
InstanceType<T> | T extends abstract new (...args: any[]) => infer R ? R : any |
注意 Exclude 与 Extract 都依赖分发:它们遍历联合的每个成员,逐个判断是否要留下。所以 Exclude<"a" | "b" | "c", "a"> 得到 "b" | "c"。
type Status = "idle" | "loading" | "success" | "error";
type ActiveStatus = Exclude<Status, "idle">; // "loading" | "success" | "error"
type ErrorStatus = Extract<Status, "error">; // "error"
这些工具类型的完整清单与组合技巧见 10.1 内置工具类型全解 。
常见坑与报错
坑一:把 extends 当成相等判断。 "a" extends string 是 true,反过来 string extends "a" 是 false。条件类型判断的是「可赋值性」,顺序不能反。
坑二:忘记 never 与 any 的特殊行为。 记住三条:never 分发后还是 never;any 在 T extends string ? A : B 这样的判断中常让两个分支合并,但不是一律如此(例如 any extends unknown ? A : B 只得到 A);unknown 只能赋值给 unknown 和 any。
坑三:在非泛型位置误用 infer。
'infer' declarations are only permitted in the 'extends' clause of a conditional type.
坑四:函数参数逆变导致 infer 匹配失败。 用 (...args: any[]) => infer R 而不是 () => infer R。
坑五:把 infer 写在 as 里期望它做运行时工作。 类型会被擦除,infer 完全是编译期行为。要在运行时拿到真实的类型信息,需要 schema 校验方案,可参考既有专题 /typescript-runtime-validation-typesafe/
。
一个真实工程示例:提取 API 响应类型
后端接口的响应通常被包成 { code, data, message },业务代码只关心 data。用条件类型加 infer 可以自动把它剥出来:
interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
type Unwrap<T> = T extends ApiResponse<infer D> ? D : T;
type UserResp = ApiResponse<{ id: number; name: string }>;
type UserData = Unwrap<UserResp>;
// { id: number; name: string }
// 配合 ReturnType 直接从服务函数推类型
async function getUser(id: number): Promise<UserResp> {
return { code: 0, data: { id, name: "Ada" }, message: "ok" };
}
type User = Unwrap<Awaited<ReturnType<typeof getUser>>>;
// { id: number; name: string }
这三步类型运算——ReturnType 取返回类型、内置 Awaited 解 Promise、Unwrap 剥信封——全部由类型运算自动完成,业务代码里一行手写类型都不需要。这正是第 9 章想传达的核心思想:让类型从实现中生长出来,而不是平行维护两份。
延伸阅读:类型编程的更多进阶模式,可参考既有专题文章 /typescript-type-level-programming/ 与 /typescript-advanced-types/ 。
小结
- 条件类型
T extends U ? X : Y判断的是可赋值性,可以嵌套实现多分支。 - 裸类型参数上的条件类型会分发联合类型;用
[T] extends [U]可阻止分发,这也是IsNever的正确写法。 never分发后仍是never;any常让两个分支合并,但extends unknown等判断有例外;识别any可使用IsAny。infer只能出现在条件类型的extends右侧,用于在模式匹配中绑定类型变量;可配合剩余元素语法处理元组,也可带约束(TS 4.7+)。Exclude、Extract、ReturnType、Parameters、Awaited都是条件类型加infer的直接产物,掌握它们就不再需要把标准库当黑箱。
到这里,第 9 章的三块基石——keyof / 索引访问、映射类型、条件类型与 infer——已经齐了。第 10 章我们会站在它们之上,把标准库里的工具类型逐个拆开看,并进入模板字面量类型与递归类型的深水区。
阅读导航:上一节:9.2 映射类型与键重映射(as) · 下一节:10.1 内置工具类型全解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。