Promise.all in TypeScript
Promise.all takes a list of promises and gives you back one promise that resolves when every
one of them has resolved. That is the runtime behaviour, and it takes a paragraph to explain.
The TypeScript side is longer and more useful. Promise.all is one of the few standard-library
functions whose return type is computed with a mapped type over a
tuple, and understanding that mapped type is the difference between
getting [User, Post, number] back and getting (User | Post | number)[] — which is the same data
with all the useful type information thrown away.
What Promise.all does
Pass it an array of promises, await the result, destructure.
declare function fetchUser(): Promise<{ id: number }>
declare function fetchPosts(): Promise<string[]>
async function loadDashboard() {
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()])
return { user, posts }
}Both requests start immediately and run concurrently. The await finishes when the slower of the
two finishes, not when both have finished in sequence.
The alternative is worth spelling out, because avoiding it is the whole reason the function exists:
declare function fetchA(): Promise<string>
declare function fetchB(): Promise<string>
async function sequential() {
const a = await fetchA() // ❌ nothing else starts until this resolves
const b = await fetchB()
return [a, b]
}Two awaits on separate lines run one after the other. If each request takes 200ms, that version
takes 400ms and the Promise.all version takes 200ms. The types are identical, so the compiler will
never point this out — it is purely a reading-the-code problem, which is why it survives so long in
real codebases.
The rejection behaviour is fail-fast: if any promise rejects, the promise returned by
Promise.all rejects with that first reason, right away. It does not wait for the others, and it
does not cancel them either. The remaining requests keep running in the background, and if one of
them rejects later you get an unhandled rejection. That surprises people, so it is worth saying
plainly — Promise.all gives up on the results, not on the work.
The signature
Here is the declaration from the standard library:
interface PromiseAllExample {
all<T extends readonly unknown[] | []>(
values: T,
): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>
}Three pieces are doing the work.
T extends readonly unknown[] | [] is the constraint that makes tuple inference happen. The
strange-looking | [] is a hint to the compiler: when a constraint includes an empty tuple,
TypeScript infers a tuple type for the argument instead of widening it to an array. Without it,
[fetchUser(), fetchPosts()] would come in as (Promise<User> | Promise<string[]>)[] and the
result types would collapse.
{ -readonly [P in keyof T]: ... } is a mapped type over the tuple. Mapping over a tuple gives
back a tuple of the same length, with each position transformed. The -readonly modifier strips
readonly off the result, so you can still push to what comes back even if you passed a
readonly tuple in.
Awaited<T[P]> unwraps each promise at that position, recursively. Awaited<Promise<Promise<number>>>
is number. If a position is not a promise at all, Awaited leaves it alone — which is why you can
mix plain values into the array and they come out unchanged.
Plain values are allowed
Because Awaited<T> leaves non-promises alone, you can mix already-resolved values into the array
and the positions still line up:
declare function fetchName(): Promise<string>
async function withPlainValues() {
const [id, name] = await Promise.all([42, fetchName()])
// ✅ id: number, name: string
return { id, name }
}That is handy when some of your values come from a cache and some do not. You do not have to wrap
the cached ones in Promise.resolve to keep the shape consistent.
Tuple inference vs. array inference
This is the one thing to remember. Write the array inline and you get a tuple:
declare function fetchUser(): Promise<{ id: number }>
declare function fetchFlags(): Promise<string[]>
declare function fetchCount(): Promise<number>
async function loadTuple() {
const results = await Promise.all([fetchUser(), fetchFlags(), fetchCount()])
// ✅ results: [{ id: number }, string[], number]
const [user, flags, count] = results
return { id: user.id, flagCount: flags.length, count }
}Pull the array out into a variable first and you lose it:
declare function fetchUser(): Promise<{ id: number }>
declare function fetchCount(): Promise<number>
async function loadWidened() {
const jobs = [fetchUser(), fetchCount()]
// jobs: (Promise<{ id: number }> | Promise<number>)[]
const results = await Promise.all(jobs)
// ❌ results: ({ id: number } | number)[]
const [user] = results
// ❌ user: { id: number } | number
}Nothing about Promise.all changed here. The const jobs = [...] line widened the array before
Promise.all ever saw it: TypeScript infers T[] for an array literal assigned to a variable, and
the element type becomes a union of what is inside. Every position now
holds the same union, so you have to narrow before you can touch anything. (With
noUncheckedIndexedAccess on — it is not part of strict — that destructured user would also
pick up | undefined; the tuple cases above never do, because indexing a fixed-length tuple in
range is always safe.)
The fix is as const, which keeps the tuple shape:
declare function fetchUser(): Promise<{ id: number }>
declare function fetchCount(): Promise<number>
async function loadWithAsConst() {
const jobs = [fetchUser(), fetchCount()] as const
// jobs: readonly [Promise<{ id: number }>, Promise<number>]
const [user, count] = await Promise.all(jobs)
// ✅ user: { id: number }, count: number
return { user, count }
}The readonly on jobs is fine — the constraint accepts readonly tuples, and the -readonly
in the mapped type means the result is a normal mutable tuple anyway.
The array-of-uniform-things case is different, and there the union is correct. Promise.all has a
second overload for iterables that returns Awaited<T>[], so mapping over a list gives you a plain
array and that is exactly what you want:
declare function loadPage(page: number): Promise<string[]>
async function loadPages(pages: number[]) {
const results = await Promise.all(pages.map(loadPage))
// ✅ results: string[][] — a real array, because the input is a real array
return results.flat()
}Fixed set of different things: tuple. Variable number of the same thing: array. The typing follows the shape of the problem.
Typing the failure path
Promise.all says nothing about what a rejection contains, because JavaScript lets you reject with
anything. Under strict, that means unknown in the catch clause:
declare function fetchUser(): Promise<{ id: number }>
declare function fetchCount(): Promise<number>
async function loadOrFail() {
try {
const [user, count] = await Promise.all([fetchUser(), fetchCount()])
return { user, count }
} catch (error) {
// error: unknown
if (error instanceof Error) {
console.error(error.message)
}
return null
}
}You cannot skip the narrowing, and you should not want to. The whole point of
useUnknownInCatchVariables (which strict turns on) is that the compiler stops pretending it
knows something it cannot know.
There is a second problem the types will not warn you about: you also lost which call failed. Both
requests reject into the same catch, and the reason rarely tells you which of the two produced it.
A stack trace from a fetch wrapper three layers down is not going to name the call site. If you care
about the difference — retrying one endpoint, degrading one panel of a page — Promise.all is the
wrong tool and allSettled is the right one.
Promise.allSettled and narrowing the results
allSettled never rejects. It waits for everything and hands back one result object per position,
each one a discriminated union on status:
declare function fetchUser(): Promise<{ id: number }>
declare function fetchCount(): Promise<number>
async function loadSettled() {
const [userResult, countResult] = await Promise.allSettled([
fetchUser(),
fetchCount(),
])
// userResult: PromiseSettledResult<{ id: number }>
if (userResult.status === 'fulfilled') {
return userResult.value.id // ✅ narrowed to PromiseFulfilledResult<{ id: number }>
}
console.error(userResult.reason, countResult.status)
return null
}value only exists on the fulfilled branch, so the status check is not a formality — it is the
only way the compiler will let you read the result. Note that reason is typed any, one of the
few places the standard library still gives up.
The common pattern is keeping the successes and dropping the failures, which needs a type predicate
because filter on its own does not narrow:
declare function fetchChunk(page: number): Promise<string[]>
async function loadWhatYouCan(pages: number[]) {
const results = await Promise.allSettled(pages.map(fetchChunk))
return results
.filter(
(r): r is PromiseFulfilledResult<string[]> => r.status === 'fulfilled',
)
.flatMap((r) => r.value)
// ✅ string[]
}The r is PromiseFulfilledResult<string[]> return type is what turns a boolean check into a
narrowing one. Without it you would get PromiseSettledResult<string[]>[] back and .value would
be an error on the next line.
The rest of the family
Four combinators, four different questions:
| Method | Settles when | Resolves with | Rejects when |
|---|---|---|---|
Promise.all | all fulfil, or one rejects | a tuple of values | the first rejection |
Promise.allSettled | all settle | a tuple of results | never |
Promise.any | one fulfils, or all reject | the first value | all rejected |
Promise.race | one settles, either way | the first value | the first settle rejects |
declare function fromCache(): Promise<string>
declare function fromNetwork(): Promise<string>
async function fastestSuccess() {
return Promise.any([fromCache(), fromNetwork()])
// ✅ Promise<string> — ignores rejections until every one has rejected
}
async function firstToSettle() {
return Promise.race([fromCache(), fromNetwork()])
// ✅ Promise<string> — a rejection wins too, which is what makes it a timeout tool
}any and race both flatten to Awaited<T[number]> rather than mapping over the tuple, since only
one of them comes back. If the inputs have different types, that means you get a union — the same
union you would get by hand.
The rejection types differ too. Promise.any rejects with an AggregateError carrying every
individual reason on its errors property, and only once every input has rejected. Promise.race
rejects with whatever the first settled promise rejected with, which is what makes it the standard
timeout pattern: race the real work against a promise that rejects on a timer, and the loser is
ignored. Neither of them cancels anything either — same caveat as Promise.all.
One caveat before you reach for it
Promise.all starts everything at once. That is the feature, and it is also the failure mode:
declare function upload(file: string): Promise<void>
async function uploadAll(files: string[]) {
// ❌ 500 files is 500 concurrent requests
await Promise.all(files.map(upload))
}
async function uploadInBatches(files: string[], size = 5) {
for (let i = 0; i < files.length; i += size) {
// ✅ at most `size` in flight at a time
await Promise.all(files.slice(i, i + size).map(upload))
}
}Two or three known calls in a tuple: use Promise.all and stop thinking about it. An unbounded list
from user input or a database: batch it, or reach for a concurrency-limit helper.
Summary
Promise.all runs promises concurrently and fails on the first rejection without cancelling
anything. Its type is a mapped type over a tuple, so an inline array of different promises gives you
a precisely typed tuple back, while an array in a variable widens to a union and loses that. as const keeps the tuple. Rejections land in catch as unknown and tell you nothing about which
call failed — allSettled and a status check give you that, at the cost of narrowing every
result.
If you want to see the type machinery from the inside, the Promise.all type challenge asks you to implement that mapped type yourself, and All covers the same generic variadic-tuple ground from a different angle.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges