对联合类型做成员级别的条件排除与包含,关键不是记住内置的 Exclude 和 Extract,而是理解 TypeScript 的分布式条件类型。联合类型本身表示多个可能类型的集合,条件类型并不会天然对这个集合作整体判断,只有当类型参数处于裸参数位置时,才会把联合拆开。本文先拆解这一机制,再给出排除、包含和组合条件的实现。

一、先理解分配:条件类型为什么会把联合拆开
在 TypeScript 中,如果类型参数 T 直接写在条件类型的 extends 左侧,那么当 T 是联合类型时,条件类型会被分配到每个联合成员上。例如 T extends U ? X : Y 中的 T 是裸类型参数,TypeScript 会把 T 的每个成员分别代入判断,再将结果合并成新的联合类型。这个过程并不是运行时行为,而是类型系统在编译期完成的展开。
下面的例子可以更直观地看到分配效果。定义一个把入参包成数组的类型:
type ToArray<T> = T extends any ? T[] : never; type R1 = ToArray<string | number>; // string[] | number[] type NotDistributed<T> = [T] extends [any] ? T[] : never; type R2 = NotDistributed<string | number>; // (string | number)[]
如果想让联合类型保持整体而不是被拆开,可以把类型参数放进元组中,例如 [T] extends [U] ? X : Y。此时 T 不再处于裸参数位置,分配不会发生。这个差异在很多工具类型中非常关键,比如需要返回 (string | number)[] 而不是 string[] | number[] 时就要关闭分配。
二、排除成员:从 Exclude 到按结构过滤
标准库中的 Exclude<T, U> 是最常见的排除工具,定义就是 T extends U ? never : T。它把 T 中能赋给 U 的成员排除掉。比如一个状态联合类型要去掉错误状态,可以这样写:
type Exclude<T, U> = T extends U ? never : T; type Status = 'idle' | 'loading' | 'success' | 'error'; type WithoutError = Exclude<Status, 'error'>; // WithoutError = 'idle' | 'loading' | 'success'
这个例子中的排除依据是字面量类型。实际项目里更常见的是按对象结构排除,比如希望从事件联合中去掉所有带 x 坐标的鼠标事件。此时不需要先提取鼠标事件的联合,只需要写一个条件类型:
type Evt =
| { type: 'click'; x: number; y: number }
| { type: 'scroll'; top: number }
| { type: 'keydown'; key: string };
type WithoutMouse<T> = T extends { x: number } ? never : T;
type NonMouse = WithoutMouse<Evt>;
// NonMouse = { type: 'scroll'; top: number } | { type: 'keydown'; key: string }
这里 WithoutMouse<T> 会把每个事件成员都拿到 extends { x: number } 上检查。鼠标事件满足结构条件,返回 never 后会被自动忽略,滚动事件和键盘事件不满足条件,原样保留。这个思路可以扩展到任意形状判断,比如排除没有 key 字段的成员、排除某个 type 值等。
三、保留成员:Extract 与多条件组合
与排除相对的是保留。内置的 Extract<T, U> 定义是 T extends U ? T : never,只保留能赋给 U 的成员。比如从混合联合中只留下字符串:
type Extract<T, U> = T extends U ? T : never; type OnlyString = Extract<'a' | 1 | false, string>; // OnlyString = 'a'
如果要按更复杂的条件保留,可以先把判断逻辑抽成独立的谓词类型。下面用一个 IsString 判断是否为字符串,再在外层通过分布式条件类型逐个成员决定去留:
type IsString<T> = T extends string ? true : false; type KeepStrings<T> = T extends any ? (IsString<T> extends true ? T : never) : never; type Result = KeepStrings<'a' | 1 | 'b'>; // Result = 'a' | 'b'
多条件组合也很常见。比如一个成员既要满足 A 又要满足 B,可以写嵌套条件类型:
type AssignableToBoth<T, A, B> = T extends A ? (T extends B ? T : never) : never; type Common = AssignableToBoth<'a' | 'b' | 'c', 'a' | 'b', 'b' | 'c'>; // Common = 'b'
这种写法可读性尚可,但如果判断条件继续叠加,建议把每个判断拆成命名类型,比如 HasId<T>、HasName<T>,再组合成 KeepIfIdAndName<T>。这样既避免深层嵌套,也方便单元级别的类型测试。
四、避坑:never、any 与分配失效
用条件类型过滤联合成员时,never 有两个行为需要特别注意。第一,返回 never 的分支会把该成员从结果联合中移除,这正是排除的基础。第二,never 本身作为联合成员会被 TypeScript 自动忽略,例如 string | never 等价于 string。因此如果所有成员都被排除,结果就是 never,而不是像某些开发者预期的那样得到空联合类型或 undefined。
分配失效是另一个常见问题。当类型参数被包进数组、元组或对象属性时,联合类型就不再逐成员分配。下面这个对比很能说明问题:
type TestDistributed<T> = T extends string ? T[] : never; type A = TestDistributed<'a' | 1>; // 'a'[] type TestWhole<T> = [T] extends [string] ? T[] : never; type B = TestWhole<'a' | 1>; // never
这并不总是坏事。有时我们需要整体判断,比如限制传入的 T 必须是某个联合类型之一,此时就应该用 [T] extends [SomeUnion]。而如果目标是让每个成员单独判断,则必须保持参数裸奔。另一个容易忽略的是 any:联合类型里一旦出现 any,它可能会吸收其他成员,导致条件类型结果也变得不够精确。因此更可靠的做法是在函数入口或数据边界处先收紧 any,而不是依赖后面的工具类型去修复。
五、实战:为事件列表构建可复用过滤类型
假设项目中有一个应用事件联合,包含点击、聚焦、失焦和键盘事件。不同模块需要按 type 字段取子集,或者从事件组中排除某个类型。直接在每个模块手写条件判断很容易重复,可以封装成通用的工具类型:
type AppEvent =
| { type: 'click'; x: number; y: number }
| { type: 'focus'; target: string }
| { type: 'blur' }
| { type: 'keydown'; key: string };
type ExtractByType<E, T> = E extends { type: T } ? E : never;
type ClickOrKey = ExtractByType<AppEvent, 'click' | 'keydown'>;
// ClickOrKey = { type: 'click'; x: number; y: number } | { type: 'keydown'; key: string }
type WithoutBlur<E, T> = E extends { type: T } ? never : E;
type NoBlur = WithoutBlur<AppEvent, 'blur'>;
// NoBlur = { type: 'click'; x: number; y: number } | { type: 'focus'; target: string } | { type: 'keydown'; key: string }
这里 ExtractByType 保留指定类型的事件,WithoutBlur 负责排除某个类型。两个工具类型都依赖左侧 E 的分布式行为:当 E 是联合类型且作为裸参数出现在 extends 左侧时,每个事件成员会单独与 { type: T } 比较。
在实际项目里,还可以继续扩展出 FilterByShape<T, Shape> 和 RemoveByShape<T, Shape>,用于按字段形状而不是按具体 type 值过滤。例如只保留带有 key 属性的事件,或去掉所有包含可选字段的对象成员。掌握分配机制之后,这类工具类型就可以根据业务需要自由组装,不必局限于 Exclude 和 Extract 提供的基础能力。

