☰
type-challenges 中等题精讲:为 `Promise.all` 编写类型安全的 `PromiseAll<T>` 函数
2026/9/30 2:15:45 网站建设 项目流程
  • 示例工程

【免费下载链接】type-challenges

Collection of TypeScript type challenges with online judge

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载

导读

本篇指南围绕 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)

注意这里的两个关键细节:

  1. promise2 = 42是普通值而非 Promise,说明题目要求对「Promise 或 PromiseLike 的数组」中的非 Promise 元素原样保留;
  2. 调用处使用了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 个用例,它们恰好覆盖了本体的全部难点:

  1. 纯字面量 +as const:[1, 2, 3] as const得到只读元组readonly [1, 2, 3],映射后保持每个元素的字面量类型,结果是Promise<[1, 2, 3]>。这要求T约束必须允许只读数组。
  2. 混合 Promise 与普通值 +as const:Promise.resolve(3)被解包为number,普通值1、2原样保留,得到Promise<[1, 2, number]>。
  3. 不加as const的混合数组:数组字面量被放宽为(number | Promise<number>)[],T退化为数组而非元组,映射后得到number[],最终是Promise<number[]>。注意这里Promise.resolve(3)并未解包为3的字面量,而是先与number合并再统一解包,这正是「无as const时元素类型被提升」的典型表现。
  4. 显式指定泛型参数为Array<number | Promise<number>>:结果应为Promise<number[]>,说明泛型声明必须允许调用方显式传入数组类型参数。
  5. 显式指定(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测试。

解题思路总结:从现象到本质

把测试用例与两种解法放在一起,可以提炼出解这道题的思维主线:

  1. 先约束入参形态:T extends readonly unknown[]兼容普通数组与as const只读元组;
  2. 再用[...T]保位置:让 TypeScript 按元组语义匹配实参,保留每个位置的独立类型;
  3. 然后用映射类型逐位处理:{ [K in keyof T]: Awaited<T[K]> }保持元组结构,只替换每个元素的类型;
  4. 最后外层包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

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载
上一篇:MCprep插件深度解析:Blender中高效制作Minecraft动画的实用指南
下一篇:高性能安卓日历组件架构方案:NCalendar的可扩展时间管理技术实现

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询