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:
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 PromiseThe 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#
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:
type Fetched = Awaited<ReturnType<typeof getUser>>; // UserErrors are unknown, not Error#
This surprises people coming from other languages:
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:
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:
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#
// 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:
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:
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#
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:
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",no-misused-promises catches the other classic:
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#
const controller = new AbortController();
const res = await fetch(url, { signal: controller.signal });
controller.abort(); // stops itAbortSignal.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#
// 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 hereCommon 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.
AGENTS.md now — no email needed.