类型体操实战:手写 Awaited/FlatArray/UnionToIntersection
第24章:类型体操实战:手写 Awaited、FlatArray、UnionToIntersection
理解类型体操实战是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用手写 Awaited、FlatArray、UnionToIntersection?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 为什么要手写这些工具类型
-
- MyAwaited:递归解包 Promise
-
- FlatArray:将嵌套数组展开到指定深度
Level 1 · 你需要知道的(1-3年经验)
为什么要手写这些工具类型
TypeScript 内置了 Awaited、Parameters、ReturnType 等工具类型,但真正理解类型系统的人不会只知道用它们——他们能从零复现。手写工具类型的意义在于:你会碰到内置类型解决不了的场景,必须自己组合出来;更重要的是,这个过程会让你真正理解条件类型的求值顺序、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>> 的 U 是 Promise<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 U 在 Promise<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
为什么这样工作:逆变位置
理解这个实现需要理解协变与逆变:
- 协变(Covariant):函数返回值位置。
() => Dog可以赋给() => Animal。多个来源取联合(最宽松)。 - 逆变(Contravariant):函数参数位置。
(x: Animal) => void可以赋给(x: Dog) => 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!
原因:当 T 是 never 时,分布式条件类型的"分布"操作没有任何成员可以分发,结果直接是 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 是联合,Copy 是 A | B,单个成员是 A,A | B extends A 为 false,所以返回 true(是联合)。如果 T 不是联合,Copy 就等于 T,T 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 时,裸类型参数的分布式求值没有成员可以分发,结果直接是 never。FlatArray<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 参数 |