- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
导读
本篇指南围绕 type-challenges 中等难度第 20 题 Promise.all(作者 Anthony Fu,标签array、promise)展开,目标是为一个接收「Promise 或 PromiseLike 对象数组」的函数PromiseAll补全类型签名,使其返回值被精确推导为Promise<T>,其中T是每个输入解析结果按位置一一对应的数组。读完本文,你将掌握条件类型 + 递归分发推导 Promise 解析结果的核心套路,理解as const、Awaited、元组/数组不同分发语义对类型推导的影响,并能直接对照本仓库的测试用例验证你的解法。
题目原貌与目标
题目要求用一句话概括即:给函数PromiseAll指定类型,它接受元素为 Promise 或类似 Promise(PromiseLike)的对象的数组,返回值应为Promise<T>,其中T是这些 Promise 结果组成的数组。
题目给出的起点模板位于 template.ts,是一个完全未做类型约束的占位声明:
declare function PromiseAll(values: any): any显然any会完全丢失类型信息:无论传入什么,返回值都是any,调用方拿不到任何推导结果。我们的任务就是把这个签名改写为:
declare function PromiseAll<T extends readonly unknown[]>( values: [...T] ): Promise<{ [K in keyof T]: Awaited<T[K]> }>题面还给出了一个期望推导的示例(见 README.md 与 README.zh-CN.md):
const promise1 = Promise.resolve(3); const promise2 = 42; const promise3 = new Promise<string>((resolve, reject) => { setTimeout(resolve, 100, 'foo'); }); // 应推导出 `Promise<[number, 42, string]>` const p = PromiseAll([promise1, promise2, promise3] as const)注意这里的两个关键细节:
promise2 = 42是普通值而非 Promise,说明题目要求对「Promise 或 PromiseLike 的数组」中的非 Promise 元素原样保留;- 调用处使用了
as const,把数组字面量固化为只读元组,从而保留每个元素的位置与字面量类型,最终推导结果才是Promise<[number, 42, string]>这样的元组形态,而不是被放宽为Promise<(number | string)[]>[]之类的联合数组。
题目元数据可在 info.yml 中确认:difficulty: medium,tags: [array, promise],作者为 Anthony Fu。
前置知识:从Awaited说起
在给出完整解法之前,先铺垫一个本仓库中同样关键的配套知识点——Awaited(即 00189-easy-awaited 一题的MyAwaited)。它是本题的核心「拆包」工具,负责把Promise<T>递归地解析为T。
参考该题的测试用例 test-cases.ts,可以看到MyAwaited需要处理的能力边界:
type X = Promise<string> type Y = Promise<{ field: number }> type Z = Promise<Promise<string | number>> // 多层嵌套 type Z1 = Promise<Promise<Promise<string | boolean>>> type T = { then: (onfulfilled: (arg: number) => any) => any } // thenable 对象 type cases = [ Expect<Equal<MyAwaited<X>, string>>, Expect<Equal<MyAwaited<Y>, { field: number }>>, Expect<Equal<MyAwaited<Z>, string | number>>, Expect<Equal<MyAwaited<Z1>, string | boolean>>, Expect<Equal<MyAwaited<T>, number>>, ]从中可以提炼出三个能力要求:
- 递归:
Z1是三层嵌套,需要不断解包直到最内层; - PromiseLike 兼容:
T只是拥有then方法的 thenable 对象,并非真正的Promise实例,也要能解析出onfulfilled的参数类型; - 联合分发:
string | number这样的联合类型需要被逐一解包后再合并。
TypeScript 内置的Awaited<T>正是官方提供的「递归解包」类型(TS 4.5+),其行为与上面测试完全一致。它基于条件类型的可分配性判断实现:
type Awaited<T> = T extends null | undefined ? T // 保留 null / undefined : T extends object & { then(onfulfilled: infer F): any } ? F extends ((value: infer V, ...args: any) => any) ? Awaited<V> // 取出 resolve 值并递归 : never : T // 非 thenable,原样返回条件类型T extends ... ? ... : ...对裸类型参数具有分发(distributive)特性:当T是联合类型时,会分别对每个成员求值再合并结果,这正是Awaited<string | number>能正确返回string | number的原因。
解法一:基于内置Awaited的映射类型
利用 TypeScript 内置工具类型与映射类型(mapped type),可以写出最简洁的标准答案:
declare function PromiseAll<T extends readonly unknown[]>( values: [...T] ): Promise<{ [K in keyof T]: Awaited<T[K]> }>逐步拆解:
| 组成部分 | 作用 |
|---|---|
T extends readonly unknown[] | 泛型约束:入参必须是数组或只读数组(as const产生只读元组) |
values: [...T] | 展开T为元组,让 TS 将字面量数组推导为「按元素逐一保存」的元组类型而非普通数组 |
{ [K in keyof T]: ... } | 映射类型:保持元组的顺序与长度,逐个处理每个位置的元素类型 |
Awaited<T[K]> | 对第K个位置的类型递归解包 Promise / PromiseLike |
Promise<...> | 外层包裹,模拟真实Promise.all的返回值形态 |
为什么必须写成values: [...T]?因为如果直接写values: T,TypeScript 对数组字面量默认会推断为普通数组(number[]),会丢失「第几个元素是什么类型」的信息;而可变元组展开[...T]会强制把实参按元组语义匹配T,让T保留每个位置的精确类型。这正是题面示例中as const与[...T]相互配合的意义。
再看映射类型为什么用keyof T而非T[number]:元组的keyof T是"0" | "1" | "2"这样的数字字面量键集合,映射后仍产出等长的元组;若用T[number]则会退化为联合类型,丢失位置信息。
解法二:条件类型 + 递归的手写实现
如果不用内置Awaited,也可以借助infer与递归自己「拆包」,理解底层原理。先写一个简易版递归拆包:
type MyAwaited<T> = T extends PromiseLike<infer R> ? R extends PromiseLike<any> ? MyAwaited<R> // 仍是 PromiseLike,继续递归 : R // 拆到底了 : T // 非 PromiseLike,原样返回然后在PromiseAll中逐位置应用:
type MapAwaited<T extends readonly unknown[]> = { [K in keyof T]: T[K] extends PromiseLike<infer R> ? MyAwaited<R> : T[K] } declare function PromiseAll<T extends readonly unknown[]>( values: [...T] ): Promise<MapAwaited<T>>这里核心是infer的用法:在条件类型的分支中通过infer R声明一个待推断的类型变量,让 TypeScript 从PromiseLike<infer R>中提取出R,再配合递归处理多层嵌套。它验证了本题的本质——题目并不要求我们重新实现 Promise 语义,而是用类型系统模拟「递归解包」这一运算。
需要注意的是:手写版本的分发语义与内置Awaited在边界细节上(如null/undefined的保留、never的处理)可能不完全一致,因此生产代码中优先使用官方Awaited更稳妥。
用测试用例验证解法
本仓库为每一题都配备了可运行的测试文件。本题的测试位于 test-cases.ts,共覆盖 5 个典型场景:
import type { Equal, Expect } from '@type-challenges/utils' const promiseAllTest1 = PromiseAll([1, 2, 3] as const) const promiseAllTest2 = PromiseAll([1, 2, Promise.resolve(3)] as const) const promiseAllTest3 = PromiseAll([1, 2, Promise.resolve(3)]) const promiseAllTest4 = PromiseAll<Array<number | Promise<number>>>([1, 2, 3]) const promiseAllTest5 = PromiseAll<(number | Promise<string>)[]>([1, 2, Promise.resolve('3')]) type cases = [ Expect<Equal<typeof promiseAllTest1, Promise<[1, 2, 3]>>>, Expect<Equal<typeof promiseAllTest2, Promise<[1, 2, number]>>>, Expect<Equal<typeof promiseAllTest3, Promise<[number, number, number]>>>, Expect<Equal<typeof promiseAllTest4, Promise<number[]>>>, Expect<Equal<typeof promiseAllTest5, Promise<(number | string)[]>>>, ]逐条解读这 5 个用例,它们恰好覆盖了本体的全部难点:
- 纯字面量 +
as const:[1, 2, 3] as const得到只读元组readonly [1, 2, 3],映射后保持每个元素的字面量类型,结果是Promise<[1, 2, 3]>。这要求T约束必须允许只读数组。 - 混合 Promise 与普通值 +
as const:Promise.resolve(3)被解包为number,普通值1、2原样保留,得到Promise<[1, 2, number]>。 - 不加
as const的混合数组:数组字面量被放宽为(number | Promise<number>)[],T退化为数组而非元组,映射后得到number[],最终是Promise<number[]>。注意这里Promise.resolve(3)并未解包为3的字面量,而是先与number合并再统一解包,这正是「无as const时元素类型被提升」的典型表现。 - 显式指定泛型参数为
Array<number | Promise<number>>:结果应为Promise<number[]>,说明泛型声明必须允许调用方显式传入数组类型参数。 - 显式指定
(number | Promise<string>)[]:number原样保留、Promise<string>解包为string,合并后得到(number | string)[],最终是Promise<(number | string)[]>。这验证了「联合类型中的每个成员各自按规则处理」的分发能力。
这些测试借助 utils/index.d.ts 中导出的Equal类型做严格相等校验(其实现为基于函数参数逆变位置的经典等价性判断):
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false由于Equal对any与具体类型的区分非常敏感,只要PromiseAll的推导结果与期望类型存在任何细微差异(例如返回了any、丢失了字面量、顺序错乱),测试就会以类型错误的形式暴露出来。这也是本仓库所有题目共用的验证机制——你可以在任意一题的test-cases.ts顶部看到import type { Equal, Expect } from '@type-challenges/utils'这一行,例如 00189-easy-awaited/test-cases.ts 中的MyAwaited测试。
解题思路总结:从现象到本质
把测试用例与两种解法放在一起,可以提炼出解这道题的思维主线:
- 先约束入参形态:
T extends readonly unknown[]兼容普通数组与as const只读元组; - 再用
[...T]保位置:让 TypeScript 按元组语义匹配实参,保留每个位置的独立类型; - 然后用映射类型逐位处理:
{ [K in keyof T]: Awaited<T[K]> }保持元组结构,只替换每个元素的类型; - 最后外层包
Promise<...>:与原生Promise.all的返回形态保持一致。
一句话记住本题:「先约束、再展开、逐位解包、整体包 Promise」。而解包这一行为本身,就是 00189-easy-awaited 中的Awaited所训练的能力——两道题在知识链上是前后衔接的,Awaited是这道Promise.all题的前置技能。
延伸思考
- 若不写
[...T]而直接写values: T,PromiseAll([1, 2, 3])会推导出Promise<number[]>而不是Promise<[1, 2, 3]>,位置信息丢失——可以自己动手对比验证; - 若要支持「某个位置是
never」或嵌套更深(Promise<Promise<Promise<...>>>)的情况,内置Awaited已自动递归处理,无需额外代码; - 本题只关心「入参数组的解析结果」,与运行时
Promise.all的并发、失败聚合等语义无关——类型层永远无法也不应该模拟运行时行为,这是编写类型工具时的重要边界意识。
完成解法后,可回到仓库根目录 README.md 或中文总览 README.zh-CN.md 查看题目索引,也可对照 README.ja.md 查看日文版题面。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
type-challenges 第 20 题 Promise.all:为 PromiseAll 编写类型推导的完整解析
type challenges 第 20 题 Promise.all:为 PromiseAll 编写类型推导的完整解析 本篇指南以 type challenge
示例工程type-challenges 第 20 题:为 Promise.all 编写精确的类型签名(数组与 Promise 解包)
type challenges 第 20 题:为 Promise.all 编写精确的类型签名(数组与 Promise 解包) 导读 本篇围绕 type chal
示例工程type-challenges 中等题精解:用模板字面量类型实现 `Capitalize<T>`
type challenges 中等题精解:用模板字面量类型实现 Capitalize<T 本篇文章基于开源仓库 type challenges(Collect
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考