分布式条件类型与类型体操
条件类型遇到裸类型参数会自动分发到联合类型的每一项 —— 这个特性既是工具类型的基石,也是很多意外结果的来源。
1. 裸类型参数触发的分发
当条件类型里的类型参数是裸的(没有被元组、数组、函数等包裹),并且传入的是联合类型时,TypeScript 会把联合拆开,对每一项分别计算,再把结果合并。触发条件两个:裸类型参数 + 联合类型。任一不满足就不分发。这就是为什么 T extends U ? X : Y 和 [T] extends [U] ? X : Y 的行为完全不同。
type ToArray<T> = T extends any ? T[] : never
type A = ToArray<string | number> // string[] | number[](分发)
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never
type B = ToArrayNoDist<string | number> // (string | number)[](不分发)
2. 分发换来的工具类型
Exclude / Extract / NonNullable 都依赖分发实现:把联合拆开,对每一项判断「要不要保留」,再合并。理解分发,这些工具类型就是一行代码的事。
分发还会作用于 infer:当 T 是函数联合时,每个函数的返回值会被分别推导再合并。这就是 ReturnType 对联合函数也成立的原因——ReturnType<(() => string) | (() => number)> 得到 string | number,而不是「取第一个函数的返回值」。
type R = ReturnType<(() => string) | (() => number)> // string | number
理解这一点,就能自己写出「对联合每一项做形状检查」的工具类型,而不必死记内置工具。
type Exclude<T, U> = T extends U ? never : T
type Extract<T, U> = T extends U ? T : never
type NonNullable<T> = T extends null | undefined ? never : T
type T1 = Exclude<'a' | 'b' | 'c', 'a'> // 'b' | 'c'
type T2 = Extract<string | number, string> // string
3. 用元组包一层关掉分发
把类型参数包进元组 [T] 就能关闭分发,让联合被当作整体判断。这不是 hack,而是必须掌握的手段——判断 never 时必须这样写,因为分发到 never 的结果仍然是 never(空联合分发后是空),裸 T extends never 会先分发,空联合分发后结果还是 never,拿不到稳定的布尔值;只有包一层的 [T] extends [never] 才能得到可靠判断。
never 是「空联合」,所以裸写 T extends never ? X : Y 时,TypeScript 会对空联合的每一项做判断——但一项都没有,结果直接是空联合 never,而不是你期望的 false。这就是为什么 IsNever 一定要用元组包住:
// 正确:元组包住,never 变成 [never] 非空,正常比较
type IsNever<T> = [T] extends [never] ? true : false
type A = IsNever<never> // true
type B = IsNever<string> // false
// 错误:裸写会翻车,结果变成 never 而不是布尔
type Wrong<T> = T extends never ? true : false
type C = Wrong<never> // never(不是 true!)
记住一条:只要想对「整个联合」下结论(是不是 never、是不是某个精确类型、能不能整体赋值),就用 [T] 包住。 分发是默认行为,关掉它是显式选择。
拆开 Exclude 与 Omit
逐个看源码级实现:Partial 是映射类型加可选修饰符;Required 把可选去掉;Pick<T, K> 用映射类型只保留 K 里的键;Omit<T, K> 是 Pick<T, Exclude<keyof T, K>>;Record<K, V> 是键到值的映射。看懂这些,就看清了「工具类型的基石是分发 + 映射」。
type Partial<T> = { [K in keyof T]?: T[K] }
type Pick<T, K extends keyof T> = { [P in K]: T[P] }
type Record<K extends keyof any, V> = { [P in K]: V }
掌握分发 + 映射之后,可以自己拼出更实用的工具类型。比如「只保留对象里值满足某条件的键」「给所有字段加前缀」——思路都是先用映射类型遍历键,再用条件类型在每一项上判断。写的时候把中间结果拆成命名别名,每一步都能 hover 看看推导对不对,比一行写完再猜强得多。
类型体操的边界在哪
类型体操的三条红线:可读性、编译耗时、错误信息可读性。超过三层递归或三层嵌套的类型,就该考虑拆成多个命名类型,或加注释分步说明。调试手段很朴素:用中间类型别名分步查看推导结果,用 hover 或 // ^? 注查看 TS 推导出的类型。
type Step1 = Exclude<keyof User, 'id'>
type Step2 = Pick<User, Step1> // 分步拆解,每步都能 hover 查看
分布式条件类型的官方描述在 TypeScript 手册:Conditional Types,裸类型参数才会分配这一条值得原文确认。