Skip to content

06 · Async/Await with Types

Every async function returns a Promise, and TypeScript tracks exactly what that promise resolves to. Once you know how Promise<T> composes with generics, async/await, and error handling, typed asynchronous code stops feeling different from typed synchronous code.

The type of an async function

async function getGreeting(): Promise<string> {
  return "Hello!";
}

// Note: you write the RESOLVED type, not `Promise<Promise<string>>` --
// TypeScript automatically wraps a plain `string` return in a Promise.

getGreeting().then((message) => console.log(message));   // Hello!

async function main1(): Promise<void> {
  const message = await getGreeting();   // `message` is `string`, already unwrapped
  console.log(message.toUpperCase());    // HELLO!
}

main1();

Inside an async function, await unwraps a Promise<T> down to T. Outside one, you work with the Promise<T> itself via .then().

Typing a function that wraps a real async operation

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchUserName(id: number): Promise<string> {
  await delay(50);   // simulate network latency
  const names: Record<number, string> = { 1: "Ada", 2: "Grace" };
  return names[id] ?? "Unknown";
}

async function main2(): Promise<void> {
  const name = await fetchUserName(1);
  console.log(`User: ${name}`);   // User: Ada
}

main2();

Awaiting multiple promises with Promise.all

Promise.all preserves the individual types of each promise in a tuple, not a generic array:

async function fetchThree(): Promise<void> {
  const [name, age, active] = await Promise.all([
    Promise.resolve("Nadia"),
    Promise.resolve(31),
    Promise.resolve(true),
  ]);

  // name: string, age: number, active: boolean -- each correctly typed,
  // not widened to `(string | number | boolean)[]`
  console.log(`${name}, ${age}, active=${active}`);   // Nadia, 31, active=true
}

fetchThree();

Error handling: try/catch and the unknown catch type

Since TypeScript 4.4, values caught in a catch block are typed unknown by default (previously any) — a deliberate safety improvement, since JavaScript allows throwing literally anything, not just Error objects:

async function riskyOperation(shouldFail: boolean): Promise<string> {
  if (shouldFail) {
    throw new Error("Something went wrong");
  }
  return "success";
}

async function main3(): Promise<void> {
  try {
    const result = await riskyOperation(true);
    console.log(result);
  } catch (err) {
    // err is `unknown` here -- you cannot call err.message directly
    // without narrowing first.
    // console.log(err.message);
    // error TS18046: 'err' is of type 'unknown'.

    if (err instanceof Error) {
      console.log(`Caught: ${err.message}`);   // Caught: Something went wrong
    } else {
      console.log("Caught a non-Error value:", err);
    }
  }
}

main3();

Treat every catch as "something was thrown, I don't yet know its shape" — instanceof Error is the standard narrowing check before touching .message or .stack.

Modeling typed success/failure without exceptions

Throwing works, but for expected failure cases (a lookup that might not find anything, a validation that might fail) many codebases prefer to return a typed result instead of throwing — it forces every caller to handle the failure case explicitly, since the type system won't let them ignore it silently:

type FetchResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

async function fetchConfigValue(key: string): Promise<FetchResult<string>> {
  const store: Record<string, string> = { theme: "dark" };
  if (key in store) {
    return { ok: true, data: store[key] };
  }
  return { ok: false, error: `No config value for "${key}"` };
}

async function main4(): Promise<void> {
  const result = await fetchConfigValue("theme");

  if (result.ok) {
    console.log(`theme = ${result.data}`);   // theme = dark
  } else {
    console.log(result.error);
  }
}

main4();

This is the same Result<T, E> shape introduced with generics in Module 2 — async code and typed error handling combine naturally once you have the pattern.

A trap: forgetting await doesn't error, it just misbehaves

Calling an async function without await is valid TypeScript — it returns a Promise<T>, which is a real value — but it's almost always a bug:

async function getCount(): Promise<number> {
  await delay(10);
  return 5;
}

async function main5(): Promise<void> {
  const count = getCount();   // BUG: missing `await` -- count is Promise<number>
  // console.log(count + 1);
  // error TS2365: Operator '+' cannot be applied to types 'Promise<number>' and 'number'.
  // TypeScript DOES catch this specific case because `+` needs a number --
  // but a mistake like `console.log(count)` alone would compile fine and
  // just print "Promise { <pending> }" instead of the number you expected.

  const actualCount = await getCount();
  console.log(actualCount + 1);   // 6
}

main5();

The safety net here is partial: TypeScript catches misuse of an unwrapped promise when you try to use it as the wrong type, but a bare console.log(promise) or storing it without ever awaiting it compiles without complaint. Enable the no-floating-promises rule from typescript-eslint in real projects — it flags promises created but never awaited or handled.

A trap: async doesn't make code run in parallel

awaiting promises one at a time in sequence is a common performance bug that TypeScript's types won't warn you about, since both versions type-check identically:

async function sequential(): Promise<number[]> {
  const a = await delay(10).then(() => 1);
  const b = await delay(10).then(() => 2);   // waits for `a` to finish first
  return [a, b];   // takes ~20ms total
}

async function parallel(): Promise<number[]> {
  const [a, b] = await Promise.all([
    delay(10).then(() => 1),
    delay(10).then(() => 2),
  ]);   // both start immediately, run concurrently
  return [a, b];   // takes ~10ms total
}

If the two operations don't depend on each other's results, start them together with Promise.all rather than awaiting each one in turn.

How It Actually Works

Promise<T> is a generic interface (again defined in TypeScript's lib .d.ts) whose type parameter T tracks what the promise eventually resolves to — but nothing about the checker's handling of Promise<T> is aware of timing; it's purely a structural label attached to a value that JS's own event loop and microtask queue handle at runtime, completely outside the type system's control. When you write async function f(): Promise<number> { return 5; }, the checker actually performs a special-case unwrap: if your declared or inferred return type is itself a Promise<X>, the compiler auto-wraps a plain return 5 as if it were Promise<number> — and it also flattens a return somePromise inside an async function to prevent the doubly-nested Promise<Promise<number>> that naive substitution would otherwise produce, mirroring how await/native Promise resolution flattens nested thenables at runtime.

await is where type-level and control-flow-level narrowing intersect with real asynchrony: at compile time, await expr simply unwraps one layer of Promise<T> to T in the resulting expression's type — but at emit time, for anything below target: es2017, the async/await syntax is transformed into a state machine built from generator functions (function*) driven by a runtime helper (__awaiter), because native async/await didn't exist in older JS engines. This downleveling is a genuine code transformation, not type erasure — the emitted code is materially different, restructured control flow, not just annotation-stripped.

Because Promise<T>'s T is compile-time-only, nothing prevents a badly-typed async function from resolving to a value that doesn't actually match T (e.g., an untyped fetch().then(r => r.json()) chain typed as Promise<User> when the JSON body doesn't match User's shape) — the checker trusts your annotation on the boundary between untyped external data and your typed application code, the same way it trusts a type assertion, because it fundamentally cannot verify runtime data shapes.

Cheat sheet

Concept Syntax Notes
Async function return type async function f(): Promise<T> Write the resolved type T, not Promise<T> twice
Unwrap inside async const x = await promise x has the resolved type, not Promise<...>
Run in parallel await Promise.all([p1, p2]) Preserves each promise's individual type in a tuple
Catch type catch (err) { ... } err is unknown (TS 4.4+) — narrow with instanceof Error
Typed failure without throwing Promise<{ ok: true; data: T } \| { ok: false; error: string }> Forces callers to check ok before reading data
Common bug forgetting await Value stays a Promise<T>; TS only catches it if misused as T

Exercise

Write a function fetchWithRetry<T>(fn: () => Promise<T>, retries: number): Promise<T> that calls fn, and if it throws, retries up to retries more times before finally re-throwing the last error. Test it against a function that fails the first two times (using a counter closed over in the calling code) and succeeds the third time, and confirm it resolves. Then write a catch block around a call that exhausts all retries and confirm the error is narrowed with instanceof Error before you print its message.