Promise.all in TypeScript

September 10, 202610 min read
Requirements:
GenericsArrays| Unions

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:

MethodSettles whenResolves withRejects when
Promise.allall fulfil, or one rejectsa tuple of valuesthe first rejection
Promise.allSettledall settlea tuple of resultsnever
Promise.anyone fulfils, or all rejectthe first valueall rejected
Promise.raceone settles, either waythe first valuethe 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.

Share this article

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

Practice with Challenges

Put your promise.all in typescript knowledge to the test with these related challenges.

#20Promise.all
Medium
#18142All
Medium

Related Concepts

Concepts that build on or relate to promise.all in typescript.

TypeScript TuplesTypeScript GenericsTypeScript Discriminated UnionsUnion Types