《TypeScript高级编程》2.1 条件类型与分发

本节从条件类型的语法与可赋值性判定讲起,重点剖析「分发」这一最反直觉的行为:为什么裸类型参数会让联合类型被逐成员展开、如何用元组包裹关闭分发、never 在分发中的特殊性,以及它与映射类型、键重映射组合后如何实现按键值类型筛选字段。读完本节,你能读懂主流工具库的类型定义,并写出行为可预测、不会误分发的条件类型工具。

本节目标:掌握条件类型 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 与递归 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes