Skip to main content
TypeScript · lesson 15 of 21
TypeScript intermediate Lesson 15 of 21

Async TypeScript

Promises, a typed fetch wrapper, unknown errors, Promise.all/allSettled/race, AbortSignal, async generators and a concurrency queue — with real output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext --types node file.ts type-checks it (--types node loads Node’s globals; TypeScript 6 no longer includes @types packages automatically).

The fetch examples talk to this small local server. Start it in another terminal with npx tsx server.ts and leave it running; the curl calls below check it’s up:

import { createServer } from "node:http";

const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Carol" }];

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", "http://localhost");
  const delay = Number(url.searchParams.get("delay") ?? 0);
  await new Promise((r) => setTimeout(r, delay));
  const match = url.pathname.match(/^\/users\/(\d+)$/);
  const send = (status: number, body: unknown) => {
    res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
  };
  if (url.pathname === "/users") {
    const page = Number(url.searchParams.get("page") ?? 1);
    return send(200, { data: users.slice((page - 1) * 2, page * 2), hasMore: page * 2 < users.length });
  }
  const user = match && users.find((u) => u.id === Number(match[1]));
  if (user) return send(200, user);
  if (url.pathname === "/legacy/1") return send(200, { id: "1", fullName: "Alice" });
  send(404, { error: "not found" });
}).listen(4400, () => console.log("listening on http://localhost:4400"));
$ curl -s localhost:4400/users/1
{"id":1,"name":"Alice"}
$ curl -s localhost:4400/users?page=2
{"data":[{"id":3,"name":"Carol"}],"hasMore":false}
$ curl -s -i localhost:4400/users/9 | head -1
HTTP/1.1 404 Not Found

?delay=ms on any request makes the server wait before answering, which the concurrency examples use.

Typing Promises

An async function always returns a Promise<T>. Forgetting await is a type error in most places — and TypeScript has a dedicated check for the most dangerous one.

async function isAdmin(userId: number): Promise<boolean> {
  return userId === 1;
}

async function main() {
  const flag: boolean = isAdmin(1);
  if (isAdmin(2)) {
    console.log("user 2 is an admin?");
  }
}
main();
$ npx tsc --noEmit --module nodenext --types node promises.ts
promises.ts(6,9): error TS2322: Type 'Promise<boolean>' is not assignable to type 'boolean'.
promises.ts(7,7): error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined.

A Promise object is always truthy, so if (isAdmin(2)) would take the branch for every user — a real authorisation bug. Adding await in both places fixes it.

A Typed fetch Wrapper

response.json() returns Promise<any>, so every fetch is an any leak unless you wrap it once.

interface User { id: number; name: string }

async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`http://localhost:4400${url}`, init);
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  return (await res.json()) as T;
}

const user = await fetchJson<User>("/users/1");
console.log(user.name.toUpperCase());

const legacy = await fetchJson<User>("/legacy/1");
console.log(legacy.id + 1, legacy.name);

await fetchJson<User>("/users/9");
$ npx tsc --noEmit --module nodenext --types node fetch-json.ts
$ npx tsx fetch-json.ts 2>&1 | head -8
ALICE
11 undefined
/root/ts/async/fetch-json.ts:5
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
                     ^

Error: HTTP 404 for /users/9
    at fetchJson (/root/ts/async/fetch-json.ts:5:22)

It type-checks, and the first call is correct. The second shows the cost of as T: the legacy endpoint returns { id: "1", fullName: ... }, but the program believes it’s a User, so legacy.id + 1 does string concatenation and prints 11, and name is undefined. The assertion is the one place you’re trusting the server. The third call hits a 404, so the wrapper throws — and since nothing catches it, the process exits.

Checking the fields where data enters makes the trust explicit:

interface User { id: number; name: string }

function isUser(value: unknown): value is User {
  return typeof value === "object" && value !== null
    && typeof (value as Record<string, unknown>).id === "number"
    && typeof (value as Record<string, unknown>).name === "string";
}

async function getUser(path: string): Promise<User> {
  const body: unknown = await (await fetch(`http://localhost:4400${path}`)).json();
  if (!isUser(body)) throw new Error(`unexpected shape from ${path}: ${JSON.stringify(body)}`);
  return body;
}

for (const path of ["/users/2", "/legacy/1"]) {
  try {
    console.log(await getUser(path));
  } catch (e) {
    console.log((e as Error).message);
  }
}
$ npx tsc --noEmit --module nodenext --types node validate.ts
$ npx tsx validate.ts
{ id: 2, name: 'Bob' }
unexpected shape from /legacy/1: {"id":"1","fullName":"Alice"}

value is User is a type predicate: when isUser returns true, the compiler treats body as a User, with no assertion. A schema library such as Zod generates the same kind of check from one declaration.

Errors Are unknown

Under strict, a catch variable is unknown, because JavaScript can throw anything — not just Error objects.

async function risky(kind: string): Promise<string> {
  if (kind === "error") throw new Error("boom");
  if (kind === "string") throw "plain string";
  return "fine";
}

for (const kind of ["ok", "error", "string"]) {
  try {
    console.log(await risky(kind));
  } catch (err) {
    console.log(err.message);
  }
}
$ npx tsc --noEmit --module nodenext --types node catch.ts
catch.ts(11,17): error TS18046: 'err' is of type 'unknown'.
$ npx tsx catch.ts
fine
boom
undefined

The runtime shows why the compiler complains: the thrown string has no message, so the last line prints undefined. A Result type moves the check into one helper:

type Result<T> = { ok: true; value: T } | { ok: false; error: Error };

async function attempt<T>(fn: () => Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await fn() };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
  }
}

async function risky(kind: string): Promise<string> {
  if (kind === "error") throw new Error("boom");
  if (kind === "string") throw "plain string";
  return "fine";
}

for (const kind of ["ok", "error", "string"]) {
  const r = await attempt(() => risky(kind));
  console.log(r.ok ? `value: ${r.value}` : `error: ${r.error.message}`);
}
$ npx tsc --noEmit --module nodenext --types node result.ts
$ npx tsx result.ts
value: fine
error: boom
error: plain string

Callers can’t read r.value without checking r.ok first — the discriminated union enforces it.

Promise Combinators

Independent requests should overlap. Promise.all also keeps each element’s type, giving a typed tuple.

interface User { id: number; name: string }
const get = <T>(path: string) => fetch(`http://localhost:4400${path}`).then((r) => r.json() as Promise<T>);
const since = (t: number) => `${Math.round((performance.now() - t) / 100) * 100}ms`;

let t = performance.now();
const a = await get<User>("/users/1?delay=300");
const b = await get<User>("/users/2?delay=300");
console.log("sequential", a.name, b.name, since(t));

t = performance.now();
const [c, page] = await Promise.all([
  get<User>("/users/3?delay=300"),
  get<{ data: User[]; hasMore: boolean }>("/users?page=1&delay=300"),
]);
console.log("parallel", c.name, page.data.length, since(t));

const settled = await Promise.allSettled([
  get<User>("/users/1"),
  fetch("http://localhost:9").then((r) => r.json()),
]);
settled.forEach((s) => console.log(s.status, s.status === "fulfilled" ? s.value.name : (s.reason as Error).message));
$ npx tsc --noEmit --module nodenext --types node combinators.ts
$ npx tsx combinators.ts
sequential Alice Bob 700ms
parallel Carol 2 300ms
fulfilled Alice
rejected fetch failed

(Times are rounded to the nearest 100 ms, and include connection setup.) Two awaited requests in a row took more than twice as long as the same two started together. In the Promise.all result, c is a User and page is the page object — a tuple, not an array of a union. Promise.allSettled never rejects; each entry is a discriminated union on status, so s.value is only reachable after checking for "fulfilled". Port 9 has nothing listening, so that request fails.

Timeouts and Cancellation

Promise.race settles with whichever promise settles first. An AbortSignal goes further and actually cancels the request.

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms),
  );
  return Promise.race([promise, timeout]);
}

const slow = fetch("http://localhost:4400/users/1?delay=500");
try {
  await withTimeout(slow, 100);
} catch (e) {
  console.log("race:", (e as Error).message);
}

try {
  await fetch("http://localhost:4400/users/1?delay=500", { signal: AbortSignal.timeout(100) });
} catch (e) {
  console.log("abort:", (e as Error).name, (e as Error).message);
}
$ npx tsc --noEmit --module nodenext --types node timeout.ts
$ npx tsx timeout.ts
race: timed out after 100ms
abort: TimeoutError The operation was aborted due to timeout

The timeout promise is typed Promise<never> because it can only reject, so Promise.race([promise, timeout]) is still a Promise<T>. But race only stops waiting: the slow request carries on in the background. AbortSignal.timeout cancels the request itself, and fetch rejects with a TimeoutError.

Async Generators for Pagination

An async function* hides page-by-page fetching behind a for await loop.

interface User { id: number; name: string }
interface Page<T> { data: T[]; hasMore: boolean }

async function* paginate<T>(fetchPage: (page: number) => Promise<Page<T>>): AsyncGenerator<T> {
  for (let page = 1; ; page++) {
    const { data, hasMore } = await fetchPage(page);
    console.log(`  (fetched page ${page})`);
    yield* data;
    if (!hasMore) return;
  }
}

const users = paginate((page) =>
  fetch(`http://localhost:4400/users?page=${page}`).then((r) => r.json() as Promise<Page<User>>),
);

for await (const user of users) {
  console.log(user.id, user.name);
  if (user.name === "Bob") break;
}
$ npx tsc --noEmit --module nodenext --types node paginate.ts
$ npx tsx paginate.ts
  (fetched page 1)
1 Alice
2 Bob

Pages are fetched lazily, only when the loop asks for more — breaking after Bob means page 2 is never requested. T flows from the page fetcher into the loop variable, so user.name is typed without annotations in the loop.

A Worked Example: A Concurrency-Limited Queue

To fire many requests without overwhelming a server, cap how many run at once.

class AsyncQueue {
  #queue: (() => void)[] = [];
  #running = 0;
  peak = 0;
  constructor(private readonly limit: number) {}

  async add<T>(task: () => Promise<T>): Promise<T> {
    if (this.#running >= this.limit) await new Promise<void>((r) => this.#queue.push(r));
    this.#running++;
    this.peak = Math.max(this.peak, this.#running);
    try {
      return await task();
    } finally {
      this.#running--;
      this.#queue.shift()?.();
    }
  }
}

interface User { id: number; name: string }
const queue = new AsyncQueue(2);
const start = performance.now();
const users = await Promise.all(
  [1, 2, 3, 1, 2, 3].map((id) =>
    queue.add(() => fetch(`http://localhost:4400/users/${id}?delay=200`).then((r) => r.json() as Promise<User>)),
  ),
);
console.log(users.map((u) => u.name).join(", "));
console.log(`peak ${queue.peak}, ${Math.round((performance.now() - start) / 100) * 100}ms`);
$ npx tsc --noEmit --module nodenext --types node queue.ts
$ npx tsx queue.ts
Alice, Bob, Carol, Alice, Bob, Carol
peak 2, 700ms

Six 200 ms requests with at most two in flight take three rounds — 600 ms of server delay plus request overhead — and the results still come back in submission order because Promise.all preserves positions. add is generic per call, so each task’s result type flows through — users is a User[]. The finally block releases a slot even when a task throws, which is the part hand-rolled queues most often get wrong.

Practice

1. Make withTimeout cancel the underlying request by taking a function that receives an AbortSignal.

Solution
async function withTimeout<T>(run: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
  return run(AbortSignal.timeout(ms));
}

const url = "http://localhost:4400/users/2?delay=300";
for (const ms of [100, 1000]) {
  try {
    const user = await withTimeout((signal) => fetch(url, { signal }).then((r) => r.json()), ms);
    console.log(ms, "->", user);
  } catch (e) {
    console.log(ms, "->", (e as Error).name);
  }
}
$ npx tsc --noEmit --module nodenext --types node practice-1.ts
$ npx tsx practice-1.ts
100 -> TimeoutError
1000 -> { id: 2, name: 'Bob' }

Passing the signal into the operation is what lets it be cancelled; a promise that has already been created can only be ignored, not stopped.

2. Change paginate’s consumer to collect every user into an array with Array.fromAsync, and check the type.

Solution
interface User { id: number; name: string }
interface Page<T> { data: T[]; hasMore: boolean }

async function* paginate<T>(fetchPage: (page: number) => Promise<Page<T>>): AsyncGenerator<T> {
  for (let page = 1; ; page++) {
    const { data, hasMore } = await fetchPage(page);
    yield* data;
    if (!hasMore) return;
  }
}

const all = await Array.fromAsync(
  paginate((page) => fetch(`http://localhost:4400/users?page=${page}`).then((r) => r.json() as Promise<Page<User>>)),
);
const names: string[] = all.map((u) => u.name);
console.log(names);
$ npx tsc --noEmit --module nodenext --types node practice-2.ts
practice-2.ts(12,25): error TS2550: Property 'fromAsync' does not exist on type 'ArrayConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.
practice-2.ts(15,34): error TS7006: Parameter 'u' implicitly has an 'any' type.
$ npx tsc --noEmit --module nodenext --types node --target esnext practice-2.ts
$ npx tsx practice-2.ts
[ 'Alice', 'Bob', 'Carol' ]

Array.fromAsync drains an async iterable into a Promise<User[]>. Node 22 has it, but it’s newer than TypeScript 6’s default target, so the compiler doesn’t know it exists until you raise target (or lib) to esnext — the second error is just a knock-on from the first. tsx doesn’t check types, so it ran either way.

3. Run the queue with a limit of 3. What peak and total time do you expect?

Solution
class AsyncQueue {
  #queue: (() => void)[] = [];
  #running = 0;
  peak = 0;
  constructor(private readonly limit: number) {}
  async add<T>(task: () => Promise<T>): Promise<T> {
    if (this.#running >= this.limit) await new Promise<void>((r) => this.#queue.push(r));
    this.#running++;
    this.peak = Math.max(this.peak, this.#running);
    try {
      return await task();
    } finally {
      this.#running--;
      this.#queue.shift()?.();
    }
  }
}

const queue = new AsyncQueue(3);
const start = performance.now();
await Promise.all([1, 2, 3, 1, 2, 3].map((id) =>
  queue.add(() => fetch(`http://localhost:4400/users/${id}?delay=200`).then((r) => r.json())),
));
console.log(`peak ${queue.peak}, ${Math.round((performance.now() - start) / 100) * 100}ms`);
$ npx tsx practice-3.ts
peak 3, 500ms

Peak 3, and two rounds of 200 ms instead of three, so roughly 200 ms less than with a limit of 2.

Frequently Asked Questions

How do I type an async function?
An async function always returns a Promise. The return type annotation is Promise<T> where T is the resolved value type. TypeScript infers it if you omit the annotation, but explicit types on public functions are better.
How do I handle errors in async/await?
Wrap await calls in try/catch. With strict mode, the caught error is unknown — check with instanceof Error before accessing .message. Alternatively, return a Result type from the function so callers must handle both cases.
How do I type the fetch API?
fetch returns Promise<Response>. The response.json() method returns Promise<any>. Use a generic wrapper function and type assertion (or a validation library like Zod) to give the response a precise type.
Does Promise.all run promises in parallel?
Promise.all doesn't start anything itself. The work starts when each promise is created, and Promise.all waits for all of them, rejecting as soon as one rejects. Creating all the promises before awaiting any is what makes independent requests overlap instead of running one after another.
Does a type assertion on response.json() validate the data?
No. fetchJson<User>(url) or res.json() as Promise<User> only tells the compiler what to assume. If the server sends a different shape, the program carries on with wrong data typed as User. Check the fields at runtime, or use a schema library, where the data enters your program.