TypeScript的联合类型在日常编码中出现频率极高,比如string | number | boolean或者一组字面量构成的枚举联合。但当我们需要在类型层面把它拆开、按成员的特征分组、再重新组装成新的类型时,问题就变得复杂起来。这背后涉及条件类型的分发机制、infer的匹配能力以及工具类型的组合技巧。本文把这些知识点串起来,给出一套完整的分组与重组方案。

先搞清楚条件类型的分发机制
要操作联合类型,必须先理解TypeScript中所谓的分配条件类型,也就是distributive conditional type。当一个裸类型参数出现在条件类型的extends左侧时,如果传入的是联合类型,TypeScript会把联合拆开,对每个成员单独执行条件判断,最后把结果再合并成联合。这个行为是所有联合类型操作的基石。
举个最简单的例子,定义一个ToArray类型,它接收类型参数T并用T extends any做判断,返回T[]。因为T是裸类型参数,传入string | number时不会得到(string | number)[],而是得到string[] | number[]。分发行为自动发生,这就是拆分联合的关键入口。
type ToArray<T> = T extends any ? T[] : never; // 结果是 string[] | number[],而不是 (string | number)[] type Result1 = ToArray<string | number>; // 用方括号包裹T可以阻止分发,得到 (string | number)[] type ToArrayNoDist<T> = [T] extends [any] ? T[] : never; type Result2 = ToArrayNoDist<string | number>;
理解了分发机制,也就明白了Extract和Exclude的底层实现。Exclude的定义是T extends U ? never : T,利用分发逐个检查联合成员,匹配上的返回never,而never在联合中会被自动过滤掉,最终剩下的就是被排除后的联合。Extract则相反,匹配上的保留。这两个内置工具类型看似简单,实际上正是分组操作的第一把手术刀。
用分发与infer实现按类型特征分组
有了分发能力,就可以按类型特征对联合成员做分组。假设我们有这样一个联合类型,包含字符串、数字、函数和对象四种成员,目标是把字符串和数字归为原始类型组,函数单独一组,对象单独一组。思路是用多个条件类型分别筛选出符合条件的成员。
筛选函数时可以借助infer匹配函数签名,这样还能顺带提取参数和返回值,为后续重组提供素材。筛选对象时则用T extends Record<string, any>或更精确的结构约束。下面是完整的分组代码。
type Mixed = string | number | (() => void) | { id: number };
// 提取原始类型组
type Primitive<T> = T extends string | number | boolean ? T : never;
type PrimitiveGroup = Primitive<Mixed>; // string | number
// 提取函数组,并解构出参数与返回值
type Fn<T> = T extends (...args: infer A) => infer R ? T : never;
type FnGroup = Fn<Mixed>; // () => void
// 提取对象组
type Obj<T> = T extends Record<string, any>
? T extends (...args: any[]) => any ? never : T
: never;
type ObjGroup = Obj<Mixed>; // { id: number }注意对象筛选这里有个细节:函数在TypeScript中也是可以被Record约束匹配的,因为它带有索引兼容的结构,所以在Obj里必须先排除函数,否则函数会同时落入两组。这种成员同时满足多个条件的重叠问题,是分组时最常见的坑,处理方式就是让每个分组类型互相排斥,比如先匹配更特殊的结构,再匹配宽泛的结构。
另一个技巧是利用never作为分发终止符。当一个成员不满足条件时返回never,它会在最终联合中消失,等价于从分组里剔除。这也是为什么不能在返回值位置随意返回never之外还要注意,如果某个分组筛选结果为空,得到的类型就是never本身,后续使用时需要用[T] extends [never]的技巧来检测空组并做兜底处理。
把分组结果重组为目标结构
分组只是中间步骤,真正的需求往往是把分组结果重新组织。常见的重组形式有两种:一种是拼回联合类型但改变成员形态,另一种是拼成对象或元组结构。先看拼回联合的情况,比如把字符串组全部转成模板字面量类型,把数字组保持不变,直接用交叉的两个映射条件类型再做一次分发即可。
// 分组后重组:字符串加前缀,数字保持不变
type Rebuild<T> = T extends string ? `prefix_${T}` : T extends number ? T : never;
type Rebuilt = Rebuild<'a' | 'b' | 1 | 2>; // 'prefix_a' | 'prefix_b' | 1 | 2如果要拼成对象结构,比如把对象组里的每个成员转成以id为键的映射类型,可以借助映射类型配合as子句对键做转换。如果想拼成元组,则需要先把联合转成元组,这是一个经典的类型体操:利用递归条件类型,每次通过Exclude从联合中取出一个成员放入元组头部,直到联合为空。
// 联合转元组的经典递归写法
type UnionToTuple<T, L = never> =
[T] extends [never]
? L
: UnionToTuple<Exclude<T, keyof any extends never ? never : T>, L>;
// 更实用的思路:借助函数参数位置的逆变特性获取最后一个成员
type LastOf<T> =
T extends (...args: infer A) => any
? A extends readonly [...any, infer R] ? R : never
: never;
type Push<T extends any[], V> = [...T, V];
type UnionToTupleRec<T, L extends any[] = []> =
[T] extends [never] ? L : UnionToTupleRec<Exclude<T, LastOf<T>>, Push<L, LastOf<T>>>;
// 结果为 ['a', 'b', 'c'] 的某种排列
type Tuple = UnionToTupleRec<'a' | 'b' | 'c'>;这里用到了一个重要原理:函数参数位置对类型是逆变的,把联合类型放到参数的剩余位置推断时,infer只能捕获到交叉后的最后一个成员,反复用Exclude剔除已捕获的成员并递归,就能把整个联合搬运到元组中。需要注意联合类型本身没有顺序保证,得到的元组顺序在字面量成员之间存在不确定性,依赖顺序的重组场景要谨慎使用。
最后再看对象合并式的重组。如果分组结果是一批对象类型,想把它们的属性合并成一个大对象,直接用交叉会导致同名属性类型变成交叉而非覆盖。正确做法是先用keyof与映射类型遍历所有键,再用条件类型从每个对象中挑出对应属性,实现扁平化合并。
type Flatten<T> = { [K in keyof T]: T[K] } & extends never ? never : T;
// 合并一组对象类型的属性
type MergeObjects<T> = {
[K in keyof UnionToIntersection<T>]: UnionToIntersection<T>[K]
};
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void ? I : never;
type A = { name: string };
type B = { age: number };
type Merged = MergeObjects<A | B>; // { name: string; age: number }UnionToIntersection同样是利用函数参数逆变的经典手法,把联合塞进参数位置,infer推断出来就是交叉类型。拿到交叉后再用同态映射类型扁平化,就能得到干净的对象结构。
实战中的注意事项与性能考量
类型体操写出来容易,维护起来难。首先是可读性问题,递归类型和深层infer嵌套会让IDE的悬停提示变成一长串代码,建议在关键类型上写清楚注释,并导出语义明确的中间类型名,比如PrimitiveGroup、FnGroup,让团队成员能看懂每个分组阶段产出了什么。
其次是递归深度限制。TypeScript对递归类型有约1000层的栈深度限制,元组元素还有约10000个的长度上限。大多数业务场景远达不到这个量级,但如果联合成员来自自动生成的API类型,成员数可能成百上千,此时递归转元组的方案要考虑分批处理或换用映射类型的非递归写法。
还有一点值得强调:类型体操应该服务于运行时逻辑的正确性,而不是炫技。分组与重组的典型落地场景包括:把API返回的 discriminated union 按状态字段分组生成不同处理器映射、把事件名联合转成事件监听器元组、把配置项联合转成合并后的配置对象类型。这些场景下,多花时间写类型测试往往比把类型写得更花哨更有价值。可以用Expect和Equal这对工具类型对分组结果做编译期断言,确保重构类型时行为不回归。
// 编译期类型断言工具,保证类型体操的正确性 type Expect<T extends true> = T; type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false; type _Test1 = Expect<Equal<PrimitiveGroup, string | number>>; type _Test2 = Expect<Equal<FnGroup, () => void>>;
掌握分发机制、never的过滤特性、infer的参数逆变推断这三大核心,再配合Extract、Exclude、递归和映射类型的组合,联合类型的分组与重组就有了完整的解题框架。遇到新的需求时,先在纸上画出分发流程,确定每个成员在每一步的命运,再逐步翻译成类型代码,复杂度自然可控。
TypeScript类型体操联合类型条件类型分发修改时间:2026-09-12 19:37:39