广告位 · 页头(后台-模块管理-新建模块后替换此区域)

TypeScript联合类型成员如何遍历与变换?类型体操核心算法实现详解

类型体操里最让人着迷也最让人头疼的题目,几乎都绕不开联合类型。比如把一组字符串字面量的联合转成元组、把联合转成交叉、给每个成员加上前缀、过滤掉某些成员等等。这些操作在运行时的JavaScript里都是一行map或者filter的事,但在类型层面,你需要理解TypeScript类型系统的一个核心特性:分布式条件类型。这篇文章就把联合类型成员的遍历与变换完整拆开讲,从原理到算法再到实战,读完你就能自己动手写出这类工具类型。

TypeScript联合类型成员如何遍历与变换?类型体操核心算法实现详解

一、分布式条件类型:联合类型遍历的底层引擎

先看一个最基础的例子。定义一个条件类型,判断类型T是否可以赋值给类型U:

type Distributed<T, U> = T extends U ? true : false;
type R1 = Distributed<'a' | 'b' | 'c', 'a'>; // true | false
// 用元组包裹,阻止分布
type NoDistribute<T, U> = [T] extends [U] ? true : false;
type R2 = NoDistribute<'a' | 'b' | 'c', 'a'>; // false

两个写法只差一层方括号,结果完全不同。原因在于TypeScript对条件类型有一条特殊规则:当extends左侧是一个裸类型参数时,如果传入的是联合类型,编译器会把联合拆开,对每个成员分别执行一次条件判断,最后把所有结果再联合起来。所以第一个例子里,'a'、'b'、'c'各自被判断一次,'a'可以赋值给'a'得到true,另外两个得到false,最终结果是true | false。而第二个例子用元组把T包了起来,破坏了裸类型参数的前提,整个联合被当成一个整体去比较,'a' | 'b' | 'c'不能整体赋值给'a',所以直接返回false。

这个分布行为就是联合类型遍历的全部基础。你在类型体操里写的每一个针对联合成员的变换,本质上都是在利用这条规则做一次隐式的循环。内置工具Exclude和Extract就是最典型的应用:

type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type R3 = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
type R4 = MyExtract<'a' | 'b' | 'c', 'a' | 'b'>; // 'a' | 'b'

注意never在联合中的特殊地位:它是空联合,任何类型与never做联合运算都会被吸收掉,相当于循环里的continue。理解了这一点,过滤算法就不再神秘。

还有一个容易被忽略的细节:boolean在类型系统里本质上是true | false的联合。当你对包含boolean的联合做分布操作时,它会被拆成两个成员分别处理,很多看起来诡异的推断结果都源于此。

二、从分布到收集:联合转交叉与联合转元组

分布解决了遍历问题,但很多算法还需要把遍历出去的结果重新收集起来。联合转交叉是第一个经典关卡,它的实现借助了函数参数位置的逆变特性:

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends
  (k: infer I) => void ? I : never;
type T1 = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a: 1 } & { b: 2 }

推导分两步。第一步,分布把{ a: 1 } | { b: 2 }变成两个函数类型的联合(k: { a: 1 }) => void | (k: { b: 2 }) => void。第二步,用infer I去匹配这个联合。TypeScript在推断函数参数时遵循逆变规则:要找一个参数类型同时兼容联合里的每一个函数,唯一解就是所有参数类型的交叉,于是I被推断为{ a: 1 } & { b: 2 }。整个过程相当于把分布出去的成员又收拢了回来。

有了联合转交叉,就可以进一步实现联合转元组。思路是:每次从联合里取出一个成员放到元组末尾,用Exclude把取出的成员从联合中删掉,然后递归处理剩下的部分,直到联合变成never为止:

type LastInUnion<U> =
  UnionToIntersection<U extends any ? (x: U) => void : never> extends
  (x: infer L) => void ? L : never;
type UnionToTuple<U, Last = LastInUnion<U>> =
  [U] extends [never] ? [] : [...UnionToTuple<Exclude<U, Last>>, Last];
type T2 = UnionToTuple<'a' | 'b' | 'c'>; // ['a', 'b', 'c'] 顺序不保证

这里的关键是LastInUnion。把每个成员包装成函数后,交叉推断会挑出联合中最特殊的那个成员,它就是被取出的末位成员。递归终止条件用的是[U] extends [never]而不是直接写U extends never,因为裸类型的never在分布时会直接返回never导致判断失效,必须用元组包裹来阻止分布。这个细节是无数人卡住的地方,值得反复琢磨。

需要特别提醒:联合类型在TypeScript内部是无序的,转换出来的元组成员顺序并不保证与书写顺序一致。如果你的算法依赖顺序,比如要求严格按字母序排列,需要先做排序处理,而不能指望UnionToTuple直接给出稳定结果。

三、变换算法实战:过滤、映射与深层变换

掌握了遍历与收集,就可以组合出各种实用的变换工具。先看成员的映射变换,把每个字面量成员映射成另一个值:

type MapMember<T> =
  T extends 'a' ? 1 :
  T extends 'b' ? 2 :
  T extends 'c' ? 3 : never;
type R5 = MapMember<'a' | 'b' | 'c'>; // 1 | 2 | 3

如果变换规则有规律可循,用模板字面量类型配合映射类型会更优雅。比如把一组事件名转成一个键值对象:

type UnionToRecord<U extends string> = {
  [K in U]: `on-${K}`;
};
type R6 = UnionToRecord<'click' | 'focus'>;
// { click: 'on-click'; focus: 'on-focus' }

映射类型的[K in U]写法本身就是对字符串联合的遍历,每个成员都会生成一个属性。再配合键重映射语法as,还能在遍历时对键名做二次变换,比如给所有键加上统一前缀:

type PrefixKeys<U extends string> = {
  [K in U as `data-${K}`]: K;
};
type R7 = PrefixKeys<'id' | 'name'>;
// { 'data-id': 'id'; 'data-name': 'name' }

最后是深层变换,也就是递归地处理嵌套结构,同时正确对待联合成员。以深层只读为例:

type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T
    : T extends object
      ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
      : T;
type Config = {
  host: string;
  nested: { port: number };
  tags: 'prod' | 'dev';
};
type FrozenConfig = DeepReadonly<Config>;

这个实现里,条件类型对T的分布同样在起作用:如果某个属性的值是联合类型,每个成员都会被单独判断,是对象就走映射分支,是基本类型就原样返回。函数类型需要单独排除,否则函数会被映射类型误处理成空对象。这种递归加分布的组合,就是类型体操里最通用的算法骨架,掌握了它,深层Partial、深层Pick等变体都能顺手写出来。

四、避坑指南:never、递归深度与编译性能

写这类算法时有几个高频坑。第一个是never的判断。前面提过,裸类型的never在条件类型里会让判断直接失效,所以判断一个类型是否为never必须写成[T] extends [never]。同理,判断any要用0 extends 1 & T这种特殊技巧,因为any在条件类型里会让两个分支同时命中。

第二个坑是递归深度。TypeScript对类型递归有实例深度限制,过深的递归会报类型实例化过深的错误。缓解方式包括:尽量把递归写成尾递归形式,也就是让条件类型的真分支直接返回递归调用的结果,减少中间类型的展开次数;以及在确实需要处理超长联合时,考虑分批处理而不是一次性递归到底。

第三个坑是性能。联合转元组这类算法的复杂度是成员数量的平方级别,每取一个成员都要对剩余联合做一次完整推断。成员数量在十几个以内时毫无压力,但一旦遇到几十上百个成员的联合,编译时间会明显变长。工程实践中要评估是否真的需要在类型层面做这件事,有些场景把联合信息在构建期生成为显式的工具类型,比在编译器里跑复杂算法更划算。

最后用一张对照表总结本文涉及的核心工具:

工具类型作用核心机制
Exclude 与 Extract过滤联合成员分布式条件类型加never吸收
UnionToIntersection联合转交叉函数参数逆变推断
UnionToTuple联合转元组取末位成员加递归Exclude
映射类型成员转属性in语法遍历字符串联合
DeepReadonly深层变换递归与分布的组合

联合类型的遍历与变换,说穿了就是分布式条件类型、infer推断和递归这三块积木的排列组合。把本文的几个算法亲手敲一遍,用编辑器的悬浮提示观察每一步的推断结果,再回头看那些复杂的类型体操题目,你会发现大部分套路其实都已经见过面了。

广告位 · 文章底部(后台-模块管理替换此区域)