本节目标:掌握条件类型
T extends U ? X : Y的语法与求值规则;彻底理解「分发」为什么发生、什么时候发生、如何关掉它;能读懂主流工具库的类型定义,并写出行为可预测的条件类型工具。
2.1 条件类型与分发
在 1.3 类型推导算法与上下文类型
里,我们看过推导算法如何在函数调用处补全类型参数。但推导只能「猜出」类型,无法表达「根据输入类型决定输出类型」这种逻辑。条件类型(conditional types)补上了这一块,它就是类型系统里的 if;而「分发」(distributive)是它最反直觉、也最容易被误用的行为。
一、语法与求值规则
条件类型的形式是:
type IsString<T> = T extends string ? true : false;
它读作「若 T 可赋值给 string,则结果是 true,否则是 false」。这里的 extends 不是类继承,而是 1.1 结构化类型与兼容性判定
讲过的可赋值性判定:
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
type C = IsString<string>; // true
type D = IsString<string | number>; // boolean(不是 true | false 的字面展示)
最后一行是关键:string | number 整体并不满足「可赋值给 string」,但因为分发,它被拆成 string 与 number 分别判定,得到 true | false,也就是 boolean。这正是本节要讲的核心机制。
需要强调:条件类型的结果是类型,不是值。它无法参与运行时的 if 判断,也无法在函数体里用来收窄分支——这条边界在 1.2 类型擦除与运行时边界
已经讲透。
二、分发:联合类型被逐成员展开
当条件类型的被检查类型是一个「裸类型参数」(naked type parameter,即形如 T、没有被任何结构包裹的类型参数)时,如果传入的是联合类型,TypeScript 会把它逐个成员代入,最后把各成员的结果合并成联合:
type ToArray<T> = T extends unknown ? T[] : never;
// 分发过程等价于:
// ToArray<string | number>
// = (string extends unknown ? string[] : never)
// | (number extends unknown ? number[] : never)
// = string[] | number[]
type R = ToArray<string | number>; // string[] | number[]
注意结果不是 (string | number)[],而是 string[] | number[]。大量「类型为什么和我想的不一样」的问题都源于此。
这个行为在工具类型里非常有用。例如把联合类型中每个成员都包一层 Promise:
type Promisify<T> = T extends unknown ? Promise<T> : never;
type P = Promisify<'a' | 'b'>; // Promise<'a'> | Promise<'b'>
再如给每个成员加一层只读包装:
type ReadonlyEach<T> = T extends unknown ? Readonly<T> : never;
type RE = ReadonlyEach<{ a: number } | { b: string }>;
// Readonly<{ a: number }> | Readonly<{ b: string }>
三、只有裸类型参数才分发
分发只在被检查类型是裸类型参数时发生。一旦被任何结构包裹,分发就被关闭:
type NoDistribute1<T> = [T] extends [unknown] ? T[] : never;
type NoDistribute2<T> = { value: T } extends { value: unknown } ? T[] : never;
type R1 = NoDistribute1<string | number>; // (string | number)[]
type R2 = NoDistribute2<string | number>; // (string | number)[]
对比上一节的 ToArray:ToArray<string | number> 是 string[] | number[],而 NoDistribute1<string | number> 是 (string | number)[]。差别只在于是否把 T 包进元组。
元组包裹 [T] extends [U] 是关闭分发的标准惯用法,应牢记。原理是:元组类型本身不是裸类型参数,不触发分发;而 [T] 与 [U] 的兼容性判定与 T 与 U 的兼容性判定结果一致。
四、never 与分发
never 是空联合。既然分发是「对联合的每个成员展开」,对空联合展开自然也是空的:
type ToArray<T> = T extends unknown ? T[] : never;
type Empty = ToArray<never>; // never(不是 never[])
这一点常被用来做「联合成员的过滤」:
type NonNullish<T> = T extends null | undefined ? never : T;
type A = NonNullish<string | null | undefined>; // string
type B = NonNullish<null | undefined>; // never
null 与 undefined 各自被判为 never,与剩下的成员合并后得到 string。这正是标准库 NonNullable<T> 的实现思路。
分发的开关可以总结成一张表:
| 写法 | 是否分发 | T = string | number 时的结果 |
|---|---|---|
T extends U ? X : Y | 是 | X<string> | X<number> |
[T] extends [U] ? X : Y | 否 | 整体判定一次 |
T[] extends U ? X : Y | 否 | 被数组包裹 |
keyof T extends U ? X : Y | 否 | keyof T 不是裸参数 |
{ v: T } extends U ? X : Y | 否 | 被对象包裹 |
Readonly<T> extends U ? X : Y | 否 | 被映射类型包裹 |
五、分发在标准工具类型里的痕迹
标准库里最常用的两个联合操作工具,就是分发的直接产物:
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type E1 = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
type E2 = MyExtract<'a' | 'b' | 'c', 'a' | 'b'>; // 'a' | 'b'
Exclude 的语义是「从 T 里剔除能赋给 U 的成员」,靠的就是分发加 never 自动消失。这带来两个容易被忽略的边界:
type X1 = MyExclude<never, string>; // never(空联合分发还是空)
type X2 = MyExclude<string, never>; // string(没有成员能赋给 never)
type X3 = MyExclude<string, string>; // never
理解 Exclude<never, X> === never 很重要:它意味着 never 会「穿透」整条分发链,任何以 never 为输入的联合工具都会得到 never,而不是保留原类型。
六、条件类型的延迟求值
当被检查类型含未解析的类型参数时,条件类型不会立即求值,而是「推迟」到实例化时刻:
type Box<T> = T extends string ? 'string-box' : 'other-box';
// 在泛型函数内部,Box<T> 保持未解析状态
declare function make<T>(value: T): Box<T>;
const b1 = make('hi'); // Box<string> → 'string-box'
const b2 = make(42); // Box<number> → 'other-box'
这种「延迟」有两个直接后果。第一,泛型函数无法依赖条件类型做运行时分支——条件类型只在类型层存在:
function bad<T>(value: T): T extends string ? string : number {
// ✗ 无法在实现里用类型层判断,只能靠运行时检查 + 断言
return (typeof value === 'string' ? value : 1) as T extends string ? string : number;
}
第二,延迟会让某些类型错误「推迟到调用点」才暴露。写库时如果发现类型错误出现在用户代码而不是库代码里,多半就是条件类型被延迟了。
七、与映射类型组合:联合与对象互转
分发最常见的实战用法是「联合类型 → 对象」。例如把一个字符串字面量联合变成所有键都存在的记录:
type Flags = 'read' | 'write' | 'exec';
type FlagMap = { [K in Flags]: boolean };
// { read: boolean; write: boolean; exec: boolean }
映射类型 { [K in T]: ... } 会对 T 的每个成员求值,本质上和分发是同一套「逐成员展开」思想,只是产物是对象而非联合。
反过来,用条件类型加 keyof 可以把对象过滤成子集:
type PickByValueType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface Api {
id: number;
name: string;
active: boolean;
tags: string[];
}
type StringFields = PickByValueType<Api, string>;
// { name: string }
这里 as 子句(key remapping)里出现了条件类型,落在 never 上的键会被整个删掉,于是实现了「按键值类型筛选字段」。这个技巧在表单库与序列化库中出现频率极高,1.2 类型擦除与运行时边界
提到的「类型定义与运行时校验同源」问题,很多库就是用这套映射加条件类型解决的。
八、实用工具:从真实工程里提炼
把上面的机制组合起来,可以写出生产级别的类型工具。第一个是「严格相等」判定:
type IsEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
type T1 = IsEqual<string, string>; // true
type T2 = IsEqual<{ a: 1 }, { a: 1 }>; // true
type T3 = IsEqual<string, any>; // false
这个实现绕了一点:直接写 A extends B ? (B extends A ? true : false) : false 在多数情况下够用,但遇到 any 与 unknown 会判错,因为可赋值性是「宽窄」关系而非「相等」关系。用两个函数类型的兼容性做中介,等价于判断「两个类型在推导中是否被同等对待」。
第二个是从函数类型中提取返回值:
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
type F = (x: number) => string;
type RF = MyReturnType<F>; // string
这里的 infer R 属于下一节的主题,此处先记住:条件类型负责「判断」,infer 负责「提取」。
九、泛型约束与条件类型的分工
初学者常把「泛型约束」与「条件类型」混为一谈,其实二者职责相反:
// 约束:守住入口,调用方不满足就报错
function logLength<T extends { length: number }>(x: T): number {
return x.length;
}
logLength('abc'); // OK
// logLength(42); // ✗ 类型 'number' 不满足约束 '{ length: number }'
// 条件类型:不拒绝任何输入,按输入产出不同结果
type LengthOf<T> = T extends { length: infer L } ? L : never;
type L1 = LengthOf<string>; // number
type L2 = LengthOf<42>; // never
约束负责「拒绝非法输入」,条件类型负责「按输入计算输出」。 设计库 API 时两者常配合:外层用约束守住参数,内层用条件类型计算返回类型。如果只写条件类型不给约束,调用方传入错误类型时不会报错,只会静默得到 never——这种「静默失败」比编译错误更难排查。
十、常见坑与错误信息
坑 1:以为 T extends U 是「子集」判定。 在 TS 里它是可赋值性判定,方向是 T → U。写 type IsSubset<A, B> = A extends B ? true : false 时,IsSubset<{ a: 1; b: 2 }, { a: 1 }> 是 true,因为结构化的「多可赋给少」。
坑 2:在分发上不小心把联合拆开。 下面的写法想判断「T 是否是字符串数组」,但传联合时会分发:
type IsStringArray<T> = T extends string[] ? true : false;
type Bad = IsStringArray<string[] | number[]>;
// 分发后 = (string[] extends string[] ? true : false)
// | (number[] extends string[] ? true : false)
// = true | false = boolean ← 不是期望的 false
修正方式是关掉分发:
type IsStringArray<T> = [T] extends [string[]] ? true : false;
type Good = IsStringArray<string[] | number[]>; // false
坑 3:any 与 unknown 在条件类型里的特殊行为。 any 会被判定为与两侧都兼容,IsEqual<any, string> 依赖实现细节;unknown 只在被检查侧是裸参数时才表现为「未知」。写库时若类型参数可能被显式传 any,要在文档里写清楚。
坑 4:过度嵌套导致错误信息不可读。 条件类型嵌套三层以上,报错会变成一长串展开后的类型。用 type 别名给中间结果命名,能显著改善可读性,也能减少重复实例化。
坑 5:把 boolean 当成 true | false 的字面量。 IsString<string | number> 的结果是 boolean,写 if (x: boolean) 这类类型断言时无法进一步收窄。若确实需要区分,改用 [T] extends [string] 关闭分发。
十一、验证条件类型行为
条件类型没有运行时痕迹,唯一的回归手段是类型断言测试:
type Assert<T extends true> = T;
type Expect<A, B> = Assert<IsEqual<A, B>>;
type _1 = Expect<ToArray<string | number>, string[] | number[]>;
type _2 = Expect<NoDistribute1<string | number>, (string | number)[]>;
type _3 = Expect<NonNullish<string | null>, string>;
断言失败时 tsc 会直接报「类型 false 不满足约束 true」,定位成本很低。对于「期望报错」的用例,用 // @ts-expect-error 标注:
// @ts-expect-error 42 不可赋给 { length: number }
logLength(42);
这些断言放进 *.test-d.ts,在 CI 里跑一次 tsc --noEmit 即可,是类型级代码最划算的保险。
十二、与后续章节的关系
条件类型是类型级编程的「控制流」。但要真正做数据变换,还需要 infer 做模式匹配提取、需要递归做循环、需要元组做容器——这三件事在 2.2 infer 与递归
与 2.3 类型级数据结构与图灵完备
中展开。同时,条件类型嵌套过深会显著拖慢编译,3.1 类型实例化开销与测量
会讲如何量化这部分开销。若想先看类型系统在既有工程里的落地形态,可以读 TypeScript 高级类型实战
,那里的例子偏应用层,本节讲的是它背后的机制。
小结
- 条件类型
T extends U ? X : Y是类型级的判断,extends是可赋值性判定,方向是「被检查类型 → 目标类型」。 - 分发只发生在被检查类型是裸类型参数时:传入联合类型会被逐成员展开,结果合并为联合;
never作为空联合分发成never。 - 用
[T] extends [U](元组包裹)可关闭分发;T[]、keyof T、{ v: T }、Readonly<T>等被包裹的形式同样不分发。 - 分发加映射类型是「联合 ↔ 对象」互转的基石;
as子句里落在never上的键会被删除。 - 被检查类型含未解析类型参数时,条件类型会被延迟到实例化时刻求值,因此无法用它做运行时分支。
- 泛型约束负责拒绝非法输入,条件类型负责计算输出,二者职责相反、常配合使用。
- 写条件类型时先问自己「调用方会传联合吗」,若会且不希望被拆开,务必用元组包裹。
下一节我们把 infer 引入条件类型,让类型不仅能判断,还能从结构里提取出局部类型,并用递归把它变成可循环的类型级程序。
阅读导航:上一节:1.3 类型推导算法与上下文类型 · 下一节:2.2 infer 与递归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。