第 24 章

类型体操实战:手写 Awaited/FlatArray/UnionToIntersection

第24章:类型体操实战:手写 Awaited、FlatArray、UnionToIntersection

理解类型体操实战是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用手写 Awaited、FlatArray、UnionToIntersection?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么要手写这些工具类型

TypeScript 内置了 AwaitedParametersReturnType 等工具类型,但真正理解类型系统的人不会只知道用它们——他们能从零复现。手写工具类型的意义在于:你会碰到内置类型解决不了的场景,必须自己组合出来;更重要的是,这个过程会让你真正理解条件类型的求值顺序、infer 的作用位置,以及分布式条件类型这些核心机制。

本章按难度递进,每个工具类型都先展示错误的直觉写法,分析为什么失败,再给出正确实现。


1. MyAwaited:递归解包 Promise

错误的第一直觉

// 错误版本:只剥一层
type MyAwaited_Wrong<T> = T extends Promise<infer U> ? U : T;

type A = MyAwaited_Wrong<Promise<Promise<string>>>;
// 结果:Promise<string>  —— 只剥了一层,还有一层 Promise 没处理

问题:Promise<Promise<string>>UPromise<string>,条件类型不会自动递归。

正确实现

type MyAwaited<T> =
  T extends null | undefined
    ? T
    : T extends object & { then(onfulfilled: infer F, ...args: any[]): any }
      ? F extends (value: infer V, ...args: any[]) => any
        ? MyAwaited<V>
        : never
      : T;

这是 TypeScript 4.5 官方实现的思路:不直接匹配 Promise<infer U>,而是匹配 .then 方法的签名。原因是任何 thenable(不只是原生 Promise)都应该被 await,比如 jQuery 的 Deferred

简化版(只处理标准 Promise,够用于 99% 场景):

type MyAwaited<T> =
  T extends Promise<infer U>
    ? MyAwaited<U>   // 递归:U 可能还是 Promise
    : T;

// 验证
type B = MyAwaited<Promise<Promise<Promise<string>>>>;
// B = string  ✓

type C = MyAwaited<number>;
// C = number  ✓

type D = MyAwaited<Promise<number[]>>;
// D = number[]  ✓

递归终止条件:当 T 不再 extends Promise<infer U> 时,直接返回 T

关键点infer UPromise<infer U> 中推断出的 U 是 Promise 的类型参数,然后我们对 U 再次调用 MyAwaited,直到 U 不是 Promise 为止。


2. FlatArray:将嵌套数组展开到指定深度

这是本章最复杂的实现。JavaScript 的 Array.prototype.flat(depth) 在类型层面需要用递归条件类型 + 数字减法来模拟深度控制。

错误的直觉

// 错误:无法控制深度
type FlatArray_Wrong<T> = T extends (infer U)[] ? FlatArray_Wrong<U> : T;
// 这会无限递归展开,无法指定深度

辅助类型:数字减法

TypeScript 没有数字减法,但可以用元组长度技巧模拟:

// 建立一个"数字到比它小1的数字"的映射
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...number[]];
// Prev[3] = 2, Prev[1] = 0, Prev[0] = never

正确实现

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];

type FlatArray<T, Depth extends number> =
  Depth extends 0
    ? T   // 深度用尽,不再展开
    : T extends ReadonlyArray<infer Inner>
      ? FlatArray<Inner, Prev[Depth]>   // 展开一层,深度减 1
      : T;

// 测试
type Nested = number[][][];

type F0 = FlatArray<Nested, 0>;  // number[][][]
type F1 = FlatArray<Nested, 1>;  // number[][]
type F2 = FlatArray<Nested, 2>;  // number[]
type F3 = FlatArray<Nested, 3>;  // number

ReadonlyArray<infer Inner> 同时匹配可变数组和只读数组——Array<T>ReadonlyArray<T> 的子类型,所以这个写法比 infer Inner)[] 更通用。

与 JavaScript 的 Array.prototype.flat() 联动:

// 验证和内置类型的一致性
declare function myFlat<T extends readonly unknown[], D extends number = 1>(
  arr: T,
  depth?: D
): FlatArray<T, D>[];

const result = myFlat([[[1, 2], [3]], [[4]]], 2);
// 类型推断为 number[]  ✓

3. UnionToIntersection:利用逆变位置的推断

这是最需要解释原理的实现,因为它用到了 TypeScript 类型系统里最反直觉的一个规律。

错误的直觉

// 想当然的写法,行不通
type UnionToIntersection_Wrong<U> = U extends any ? U & ??? : never;
// 问题:分布式条件类型会把联合拆开处理,无法把它们合并成交叉

正确实现

type UnionToIntersection<U> =
  (U extends any ? (x: U) => void : never) extends (x: infer I) => void
    ? I
    : never;

// 验证
type UI1 = UnionToIntersection<{ a: 1 } | { b: 2 }>;
// UI1 = { a: 1 } & { b: 2 }  ✓

type UI2 = UnionToIntersection<string | number>;
// UI2 = never  ✓(string & number = never)

type UI3 = UnionToIntersection<((x: string) => void) | ((x: number) => void)>;
// UI3 = ((x: string) => void) & ((x: number) => void)
// = (x: string & number) => void = (x: never) => void

为什么这样工作:逆变位置

理解这个实现需要理解协变逆变

分步解析:

// 第一步:分布式条件类型把联合展开,每个成员变成函数类型
// U = A | B | C
// (U extends any ? (x: U) => void : never)
// = ((x: A) => void) | ((x: B) => void) | ((x: C) => void)

// 第二步:用 infer 在逆变位置推断
// ((x: A) => void) | ((x: B) => void) | ((x: C) => void)
//   extends (x: infer I) => void
// 在逆变位置,I 必须同时兼容 A、B、C 的调用
// 所以 I = A & B & C

函数参数是逆变的,当 TypeScript 需要找一个类型 I 使得 (x: I) => void 能兼容这三个函数类型的联合时,I 必须是所有参数类型的交叉。这就是逆变位置的推断行为。

这个机制是 TypeScript 类型系统设计中最精妙的细节之一。


Level 2 · 它是怎么运行的(3-5年经验)

4. TupleToUnion:元组转联合

实现

// 方法1:索引访问
type TupleToUnion<T extends readonly any[]> = T[number];

// 验证
type TTU = TupleToUnion<[string, number, boolean]>;
// TTU = string | number | boolean  ✓

// 方法2:分布式(教学用,理解机制)
type TupleToUnion2<T extends any[]> =
  T extends [infer Head, ...infer Tail]
    ? Head | TupleToUnion2<Tail>
    : never;

T[number] 是最简洁的写法:用数字索引访问元组,TypeScript 会返回所有数字索引位置的类型的联合。


5. UnionToTuple:联合转元组(最棘手的反向操作)

这是本章最难的工具类型。联合到元组的转换本质上需要对联合成员进行排序——而联合类型本身是无序的。

// 先实现一个辅助:取联合的"最后一个"成员
type LastOf<U> =
  UnionToIntersection<U extends any ? () => U : never> extends () => infer R
    ? R
    : never;

type UnionToTuple<U, Last = LastOf<U>> =
  [U] extends [never]
    ? []
    : [...UnionToTuple<Exclude<U, Last>>, Last];

// 验证
type UTT = UnionToTuple<string | number | boolean>;
// UTT = [string, number, boolean] 或其某种排列

重要警告UnionToTuple 的结果顺序是未定义行为,依赖 TypeScript 内部的联合成员排序,不同版本可能不同。在生产代码中极少需要这个操作,它主要用于类型级别的计算。

LastOf 的原理:把 U 的每个成员变成 () => 成员 的函数类型,再用 UnionToIntersection 把这些函数类型合并成交叉。函数类型的交叉在重载解析时选最后一个重载,因此得到"最后一个"成员。


6. IsNever:检测 never 类型

错误的直觉

// 错误:分布式条件类型在 T = never 时不触发
type IsNever_Wrong<T> = T extends never ? true : false;

type R = IsNever_Wrong<never>;  // 结果是 never,不是 true!

原因:当 Tnever 时,分布式条件类型的"分布"操作没有任何成员可以分发,结果直接是 never

正确实现:用方括号阻止分布

type IsNever<T> = [T] extends [never] ? true : false;

// 验证
type IN1 = IsNever<never>;     // true  ✓
type IN2 = IsNever<string>;    // false ✓
type IN3 = IsNever<0>;         // false ✓

// 对比
type Bad1 = never extends never ? true : false;  // true(直接写,不是泛型)
type Bad2<T> = T extends never ? true : false;
type Bad3 = Bad2<never>;  // never(分布式在 never 上的陷阱)

[T] extends [never]T 包进元组,元组不是裸类型参数,不触发分布式条件类型。此时 [never] 作为整体和 [never] 比较,结果是 true


7. IsUnion:检测联合类型

核心思路

type IsUnion<T, Copy = T> =
  T extends any
    ? [Copy] extends [T]
      ? false   // 如果 Copy(整个联合)extends 单个成员,说明 T 只有一个成员
      : true    // 否则 T 是联合,某个成员比整体小
    : never;

// 验证
type IU1 = IsUnion<string | number>;  // boolean(即 true,因为 T 展开了)
type IU2 = IsUnion<string>;           // false
type IU3 = IsUnion<never>;            // never

// 更精确版本
type IsUnion<T> = _IsUnion<T, T>;
type _IsUnion<T, Copy> =
  [T] extends [never]
    ? false
    : T extends any
      ? [Copy] extends [T]
        ? false
        : true
      : never;

type IU4 = IsUnion<string | number>;  // true  ✓
type IU5 = IsUnion<string>;           // false ✓
type IU6 = IsUnion<never>;            // false ✓

原理:让 T 分布展开(T extends any 触发分布),然后检查"完整的 Copy"是否 extends "当前这个成员 T"。如果 T 是联合,CopyA | B,单个成员是 AA | B extends A 为 false,所以返回 true(是联合)。如果 T 不是联合,Copy 就等于 TT extends T 为 true,返回 false。


综合实战:串联使用

// 从异步函数的返回类型提取出最终值类型
type AsyncReturnType<T extends (...args: any) => any> =
  MyAwaited<ReturnType<T>>;

async function fetchUser(id: number) {
  return { id, name: "Alice", role: "admin" as const };
}

type User = AsyncReturnType<typeof fetchUser>;
// User = { id: number; name: string; role: "admin" }  ✓

// 从对象联合提取公共方法接口
type CommonInterface<U extends object> = UnionToIntersection<U>;

type ApiA = { get(url: string): Promise<string>; auth(): void };
type ApiB = { get(url: string): Promise<string>; log(): void };
type Common = CommonInterface<ApiA | ApiB>;
// Common = { get(url: string): Promise<string> }(只有共同的方法,其他变成 never)

Level 3 · 规范怎么定义的(资深)

UnionToIntersection<U> 利用了函数参数逆变位置的推断行为——当多个函数类型的联合被匹配到单个函数签名时,参数位置的 infer 会推断出所有参数类型的交叉。IsNever<T> 必须用 [T] extends [never] 阻止分布式条件类型——当 T = never 时,裸类型参数的分布式求值没有成员可以分发,结果直接是 neverFlatArray<T, Depth> 使用 Prev 元组实现数字减法,因为 TypeScript 类型系统没有原生的数字算术。

Level 4 · 边界与陷阱(所有人)

反模式警告

反模式 问题 正确做法
T extends never ? true : false 检测 never 分布式陷阱,结果是 never [T] extends [never]
无深度限制的递归展开 TypeScript 递归深度限制 (~1000) 加 Depth 参数用 Prev 映射
UnionToTuple 依赖结果顺序 顺序是未定义行为 只用于类型计算,不依赖顺序
直接 Promise<infer U> 匹配 thenable 非标准 thenable 无法处理 匹配 .then 方法签名
在联合类型上用映射类型 映射类型不分布,需要先用 K in U 显式分布或用条件类型包裹

总结

工具类型 核心机制 难点
MyAwaited<T> 递归条件类型 + infer 匹配 thenable 而非 Promise
FlatArray<T, D> 递归 + 深度计数(Prev 映射) 数字减法的模拟
UnionToIntersection<U> 逆变位置的 infer 协变/逆变理论
TupleToUnion<T> 数字索引访问 T[number] 最简单
UnionToTuple<U> LastOf + Exclude 递归 顺序不确定,谨慎使用
IsNever<T> [T] extends [never] 阻止分布 裸类型参数分布陷阱
IsUnion<T> 分布后与原始联合比较 需要保留 Copy 参数
本章评分
4.6  / 5  (5 评分)

💬 留言讨论