Skip to content
TypeScript ts async 4 min read

async / await

The async/await syntax lets you write asynchronous code that reads top to bottom like synchronous code, while still returning promises under the hood. TypeScript fully understands this: an async function always returns a Promise<T>, and await unwraps a Promise<T> back to a T within the function body. This page covers how to type async functions, what await does to the type of an expression, the difference between sequential and concurrent awaits, async arrow functions, and typing for await...of loops.

Declaring async functions

Marking a function async changes its return type: whatever you return is wrapped in a Promise. Annotate the resolved type with : Promise<T> to make the contract explicit and catch wrong returns inside the body.

async function fetchTitle(url: string): Promise<string> {
  const res = await fetch(url);
  const data = await res.text();
  return data; // must be a string (or Promise<string>)
}

// Return type inferred as Promise<number>
async function add(a: number, b: number) {
  return a + b;
}

Inside fetchTitle, you return a string but the function’s external type is Promise<string>. The compiler verifies the returned value matches the annotation, so accidentally returning the wrong type fails right where the mistake is.

What await does to a type

The await operator unwraps a promise: applying it to a Promise<T> yields a value of type T. Applied to a non-promise it returns the value unchanged, matching Awaited<T> semantics.

async function demo() {
  const n: number = await Promise.resolve(7); // Promise<number> -> number
  const s: string = await "literal";          // string -> string (no-op await)

  const res = await fetch("/api/data");
  const json = await res.json(); // any -- json() returns Promise<any>
}

Because await only works inside async functions (or top-level in ES modules), using it elsewhere is a compile error. Note that res.json() resolves to any; you must add a type yourself, which the Typed API Responses page covers in depth.

Top-level await is allowed in ES modules ("module": "esnext" / "es2022" and up in tsconfig.json). It is not available in CommonJS output.

Sequential vs concurrent awaits

Awaiting promises one after another runs them sequentially, which is slower when they are independent. Kick off all the promises first, then await them together with Promise.all to run concurrently.

// ❌ Slow: each await blocks the next (total ≈ sum of durations)
async function sequential() {
  const user = await getUser();
  const posts = await getPosts();
  return { user, posts };
}

// ✅ Fast: both start immediately, run in parallel (total ≈ max duration)
async function concurrent() {
  const userP = getUser();   // Promise<User>
  const postsP = getPosts(); // Promise<Post[]>
  const [user, posts] = await Promise.all([userP, postsP]);
  return { user, posts };
}

In concurrent, calling the functions without await starts both immediately; the single Promise.all then waits for both. The destructured tuple keeps each value’s exact type. Only force sequencing when one call genuinely depends on a previous result.

Async arrow functions and methods

The async keyword works on arrow functions, object methods, and class methods. Type the resolved value with the usual Promise<T> return annotation.

const loadConfig = async (): Promise<Config> => {
  const res = await fetch("/config.json");
  return res.json() as Promise<Config>;
};

class UserService {
  async findById(id: string): Promise<User | null> {
    const row = await db.query(id);
    return row ?? null;
  }
}

Async arrows are common in callbacks and React effects. Returning Promise<User | null> documents that the method may resolve to null, forcing callers to handle the missing case after they await.

Async iteration with for await…of

For sources that produce values over time — async generators, paginated APIs, streams — use for await...of. TypeScript types the loop variable from the AsyncIterable<T> element type.

async function* pages(): AsyncGenerator<number[]> {
  yield [1, 2];
  yield [3, 4];
}

async function sumAll() {
  let total = 0;
  for await (const batch of pages()) {
    // batch: number[]
    total += batch.reduce((a, b) => a + b, 0);
  }
  return total;
}

The generator’s return type AsyncGenerator<number[]> flows into batch, which is inferred as number[]. Async iteration requires "target": "es2018" or later, and "lib" including esnext.asynciterable.

Common patterns at a glance

GoalPattern
Run dependent callssequential await statements
Run independent callsstart all, then await Promise.all([...])
Handle one failure of manyawait Promise.allSettled([...])
Wrap a callback APInew Promise<T>((resolve, reject) => ...)
Add a timeoutPromise.race([work, timeout])

Best Practices

  • Always annotate exported async functions with : Promise<T> for a clear contract.
  • Start independent promises first, then await Promise.all instead of awaiting sequentially.
  • Reserve sequential await for calls that truly depend on a previous result.
  • Remember await only unwraps inside async functions or top-level ESM.
  • Type the result of res.json() yourself — it is any by default.
  • Use for await...of for streams and paginated sources rather than manual recursion.
  • Avoid mixing .then() chains with await in the same function; pick one style for readability.
Last updated June 29, 2026
Was this helpful?