Foundations Updated 2026-09 View as Markdown

Async and Promises

Typing asynchronous code, and the lint rule that catches the most common bug in TypeScript and JavaScript alike.

An async function always returns a Promise, and TypeScript types it for you:

typescript
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as User;
}

const user = await getUser("1");     // User — await unwraps the Promise

The annotation is Promise<User> even though the return statement produces a User. Writing : User on an async function is an error, and it is a common early mistake.

Awaiting#

typescript
const user: User = await getUser("1");
const users: User[] = await Promise.all([getUser("1"), getUser("2")]);

Awaited<T> unwraps a promise type when you need it in a type position:

typescript
type Fetched = Awaited<ReturnType<typeof getUser>>;   // User

Errors are unknown, not Error#

This surprises people coming from other languages:

typescript
try {
  await getUser("1");
} catch (err) {
  console.log(err.message);      // error: 'err' is of type 'unknown'
}

JavaScript lets you throw anything — a string, a number, an object — so TypeScript cannot assume you caught an Error. Narrow before use:

typescript
try {
  await getUser("1");
} catch (err) {
  if (err instanceof Error) {
    console.error(err.message);
  } else {
    console.error("unknown failure", err);
  }
}

For your own error types, a discriminated union plus instanceof gives you typed handling:

typescript
class NotFoundError extends Error {
  readonly kind = "not_found";
  constructor(readonly id: string) { super(`not found: ${id}`); }
}

if (err instanceof NotFoundError) {
  console.error(err.id);         // typed
}

Running things concurrently#

typescript
// sequential — three round trips, one after another
const a = await getUser("1");
const b = await getUser("2");

// concurrent — one round trip's worth of waiting
const [a, b] = await Promise.all([getUser("1"), getUser("2")]);

Promise.all is correctly typed as a tuple, so a and b keep their individual types even when they differ:

typescript
const [user, orders] = await Promise.all([getUser("1"), getOrders("1")]);
// user: User, orders: Order[]

Promise.all rejects as soon as any input rejects. When partial success is acceptable, use allSettled, which never rejects:

typescript
const results = await Promise.allSettled([getUser("1"), getUser("2")]);
for (const r of results) {
  if (r.status === "fulfilled") console.log(r.value.email);
  else console.error(r.reason);
}

That return type is a discriminated union, so narrowing on r.status gives you value or reason — the pattern from union types and narrowing, built into the standard library.

The bug that matters most#

typescript
async function save(user: User) {
  db.write(user);          // not awaited
  return { ok: true };
}

The function returns before the write happens, the error becomes an unhandled rejection, and in Node an unhandled rejection terminates the process. This is the single most common defect in generated TypeScript and JavaScript.

Turn on the rule that catches it:

eslint.config.js
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",

no-misused-promises catches the other classic:

typescript
items.forEach(async (item) => {
  await process(item);        // forEach ignores the returned promise
});
console.log("done");          // prints immediately. nothing is done.

Use a for...of loop with await, or Promise.all over .map.

Cancellation#

typescript
const controller = new AbortController();

const res = await fetch(url, { signal: controller.signal });
controller.abort();      // stops it

AbortSignal.timeout(5000) gives you a signal that fires on its own. Threading a signal through your async functions is what makes them cancellable, and it is worth doing for anything that talks to a network.

Exercise#

typescript
// Write `loadDashboard(userId)` that:
//   - fetches the user and their orders CONCURRENTLY
//   - returns { user, orders, total } with an explicit Promise<...> return type
//   - catches failures, narrowing the caught value before reading .message
// Assume getUser(id): Promise<User> and getOrders(id): Promise<Order[]>.

// write your code here

Common questions#

Why is my caught error typed unknown?#

Because JavaScript can throw any value, so TypeScript cannot assume it is an Error. Narrow with instanceof Error before reading .message. You can set useUnknownInCatchVariables: false to get the old any behaviour, but the check is finding a real gap.

Should the return type be User or Promise<User>?#

Promise<User>. An async function always returns a promise, and annotating the unwrapped type is an error. Most of the time you can omit the annotation and let it be inferred.

Promise.all or allSettled?#

all when any failure should abort the whole operation, allSettled when you want whatever succeeded. Note that all does not cancel the other work on rejection — it just stops waiting for it, so those requests still run and still cost you.

Get the TypeScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.