Promises, async/await, and Safe Concurrency Patterns
Learn how TypeScript types asynchronous code end to end: Promise<T> and Awaited<T>, async/await, the promise combinators (all, race, allSettled), typing errors safely with unknown in catch blocks, cancellation with AbortController, and avoiding common concurrency and race-condition bugs.
Asynchronous code is where a lot of real-world TypeScript either shines — catching a forgotten await or a mistyped API response before it ships — or gets bypassed with any, because errors and timing are two of the hardest things to represent precisely in a type system. This lesson covers the full toolkit for typing async code safely.
Promise<T>, async/await, and Awaited<T>
Promise<T> represents a value that isn't available yet but eventually will be (or will fail). Marking a function async automatically wraps whatever it returns in a Promise — an async function whose body returns a string has the type Promise<string>, not string. Inside an async function, await unwraps a Promise<T> down to T, so asynchronous logic can be written and read top-to-bottom like synchronous code.
Awaited<T> is a built-in utility type that computes 'what you get after awaiting this,' correctly unwrapping even nested promises (Awaited<Promise<Promise<string>>> is string, not Promise<string>). It's most useful in generic code that needs to describe the resolved type of a promise-returning parameter without knowing its concrete type ahead of time — for example, a retry wrapper generic over Awaited<ReturnType<F>>.
A subtlety worth internalizing: a function that returns a Promise but isn't awaited doesn't block anything — it starts running immediately and the calling code moves on with a Promise object in hand, not the resolved value. Forgetting await is one of the most common async bugs, and it's caught in TypeScript's type errors far less reliably than in the actual runtime behavior, since a Promise<T> is often still assignable in places a plain T might be expected only through less obvious downstream errors.
Promise combinators: all, race, allSettled
Promise.all([p1, p2, p3]) waits for every promise to resolve and returns a single promise of a tuple, with each position typed to match its corresponding input promise — TypeScript correctly infers Promise<[User, Order[], boolean]> from Promise.all([getUser(), getOrders(), checkFlag()]), not a loosely-typed array. If any promise in the group rejects, the whole Promise.all immediately rejects with that error, even if the others were still pending.
Promise.race([p1, p2]) resolves or rejects as soon as the first promise settles (resolves or rejects), typed as a union of all the possible resolved types. It's commonly used for timeout patterns — racing a real request against a promise that rejects after N milliseconds.
Promise.allSettled([p1, p2, p3]) waits for every promise to settle regardless of outcome, and returns an array of discriminated result objects: { status: "fulfilled", value: T } or { status: "rejected", reason: unknown }. Unlike Promise.all, one failure doesn't short-circuit the rest — this makes it the right tool whenever you need to know the outcome of every operation, such as sending several independent notifications where one failure shouldn't cancel the others.
Error typing: unknown in catch and typed API responses
In a catch block, the caught value's type is unknown by default under the useUnknownInCatchVariables option (on by default under strict in modern TypeScript) — reflecting the reality that JavaScript lets you throw any value at all, not just Error instances. This forces you to narrow before using the caught value: if (e instanceof Error) { console.log(e.message); } rather than assuming .message exists.
Typing a fetch response correctly requires two separate steps that are easy to conflate: response.json() returns Promise<any> because JSON parsing carries no compile-time type information about your specific API, so annotating the awaited result as your expected DTO type (or better, validating it at runtime — covered in the JSON and API Types lesson) is your responsibility, not something fetch's types give you automatically.
A well-typed API layer also types its failure paths deliberately: a discriminated union like { ok: true; data: User } | { ok: false; error: string } as a function's return type forces every caller to handle both branches explicitly, which is often a better fit for expected, recoverable failures (a 404, a validation error) than relying solely on thrown exceptions and unknown catch narrowing.
Cancellation with AbortController, race conditions, and async disposal
AbortController provides a standard way to cancel an in-flight async operation, most commonly fetch. Its .signal (typed as AbortSignal) is passed to the operation; calling .abort() on the controller causes the operation to reject with an AbortError, which you typically want to distinguish from a real failure in your catch handling rather than treating as an error to report.
A race condition in async UI code typically looks like: a user triggers a slow request, then triggers a second faster one before the first resolves, and the first request's (now-stale) response arrives and overwrites the second's — the classic 'search-as-you-type' bug. AbortController is the standard fix: store the current request's controller, and call .abort() on it whenever a new request starts, so a stale response is discarded before it can update the UI.
Modern TypeScript (targeting a modern lib) also supports Explicit Resource Management via using declarations and Symbol.asyncDispose, letting an async resource (a database connection, a file handle, a lock) define its own cleanup logic that runs automatically when the enclosing block exits — including on an early return or a thrown error — similar in spirit to how a finally block guarantees cleanup, but scoped to the declaration itself rather than requiring a manually paired try/finally.
None of these tools eliminate the need for careful design — they give you correctly-typed building blocks (a typed AbortSignal, a typed PromiseSettledResult<T>, a typed disposer) so that the compiler can catch misuse (forgetting to check signal.aborted, mishandling a rejected combinator branch) instead of those bugs only surfacing in production.
Async function typing and combinators
Awaited<ReturnType<typeof getUser>> mechanically computes the resolved type of getUser — useful in generic helper functions that don't know the concrete async function ahead of time. Promise.all's inferred tuple type keeps user typed as User and orders typed as whatever getOrders resolves to, not a loosely unioned type.
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: User = await response.json();
return data;
}
type ApiResolved = Awaited<ReturnType<typeof getUser>>; // User
const [user, orders] = await Promise.all([getUser(1), getOrders(1)]);
const results = await Promise.allSettled([getUser(1), getUser(2)]);
for (const r of results) {
if (r.status === "fulfilled") console.log(r.value);
else console.warn(r.reason);
}Code Examples
Because err is typed as unknown, TypeScript refuses err.message without a narrowing check first — instanceof Error narrows it to the Error type, which does have .message, while the else branch safely handles the (legal in JavaScript) case of something non-Error being thrown.
if (err instanceof Error) { Common mistake: Assuming every catch block receives an Error and writing err.message directly fails to compile under useUnknownInCatchVariables, and would have been an unsafe runtime assumption anyway.
Each call to search aborts whatever request preceded it before starting a new one, so a slow, stale response can never overwrite a fresher one. The catch block specifically checks for AbortError (via DOMException) to distinguish 'cancelled on purpose' from a genuine network failure, rethrowing anything else.
async function loadProfile(id: number) {
try {
const user = await getUser(id);
return user;
} catch (err) {
// err is `unknown` here under strict settings
if (err instanceof Error) {
console.error("failed to load profile:", err.message);
} else {
console.error("failed to load profile with a non-Error value:", err);
}
return null;
}
}let currentController: AbortController | null = null;
async function search(query: string): Promise<string[]> {
currentController?.abort();
const controller = new AbortController();
currentController = controller;
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const results: string[] = await response.json();
return results;
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return []; // stale request, silently ignore
}
throw err;
}
}Promise.all vs Promise.allSettled failure behavior
Promise.all is 'all or nothing' — one rejection loses information about the rest. Promise.allSettled always tells you the outcome of every promise, at the cost of needing to check .status yourself.
Promise.all([A, B, C])
A resolves --\
B REJECTS ----+--> whole Promise.all rejects immediately (C's outcome ignored)
C resolves --/
Promise.allSettled([A, B, C])
A resolves --\
B REJECTS ----+--> resolves with [
C resolves --/ {status:"fulfilled", value: A},
{status:"rejected", reason: B's error},
{status:"fulfilled", value: C}
]Important Rules
Always await a promise you intend to use synchronously in the next line — a missing await leaves you holding a Promise object, not its resolved value, and TypeScript won't always flag the mismatch clearly.
Treat catch variables as unknown and narrow with instanceof Error (or a custom check) before accessing properties on them.
Choose Promise.all when any single failure should cancel the whole operation, and Promise.allSettled when you need the outcome of every operation regardless of individual failures.
Use AbortController for any user-triggered async operation that can be superseded by a newer one (search-as-you-type, tab switching mid-fetch) to prevent stale-response race conditions.
Annotate (or, better, runtime-validate) the resolved type of response.json() explicitly — it is typed as Promise<any> and carries zero compile-time guarantee about your actual API shape.
Where this shows up
A typed API client used throughout a real application relies on all of these pieces together: async functions returning Promise<T> for each endpoint, Promise.all to load a dashboard's independent widgets in parallel, Promise.allSettled to send a batch of non-critical background requests without one failure blocking the rest, unknown-typed catch blocks that map errors into user-facing messages, and AbortController wired into a search box or a tab-based UI so switching away from a view cancels its in-flight requests instead of letting stale data flash in later.
async function save() { updateDatabase(); // missing await console.log("saved"); } async function save() { await updateDatabase(); console.log("saved"); }
Without await, updateDatabase() starts running but save() doesn't wait for it — "saved" can log before the database write actually finishes, and any rejection from updateDatabase() becomes an unhandled promise rejection instead of being caught here.
const [user, orders] = await Promise.all([getUser(1), getOrders(1)]).catch(() => [null, null]);
let user: User | null = null; let orders: Order[] = []; try { [user, orders] = await Promise.all([getUser(1), getOrders(1)]); } catch { // handle failure explicitly }
Catching Promise.all with a fallback array loses information about which promise actually failed and produces a loosely-typed fallback tuple; an explicit try/catch (or switching to allSettled if partial success matters) keeps failure handling precise and correctly typed.
[multiple-choice] If Promise.all([getA(), getB(), getC()]) is called and getB() rejects while getA() and getC() would have resolved, what happens? A. Promise.all resolves with getA and getC's values, and getB's error is silently dropped B. Promise.all immediately rejects with getB's error, without waiting for getA/getC or exposing their outcomes C. Promise.all throws a compile-time error D. Promise.all retries getB automatically
B. Promise.all immediately rejects with getB's error, without waiting for getA/getC or exposing their outcomes — Promise.all is all-or-nothing: the first rejection anywhere in the group causes the whole combined promise to reject immediately with that rejection's reason, regardless of the state of the other promises.
[true-false] Under strict TypeScript settings, a variable caught in a catch (err) { ... } block is typed as unknown by default, requiring a narrowing check before you can access properties on it. A. True B. False
A. True — Because JavaScript allows throwing any value (not just Error instances), TypeScript's useUnknownInCatchVariables option (on by default under strict) types the catch variable as unknown, forcing an explicit check like instanceof Error before use.
[multiple-choice] What is AbortController primarily used for in typed async code? A. Automatically retrying a failed fetch request B. Converting a callback-based API into a Promise-based one C. Cancelling an in-flight async operation, such as superseding a stale request with a newer one D. Typing the resolved value of a Promise more precisely
C. Cancelling an in-flight async operation, such as superseding a stale request with a newer one — AbortController provides a signal that a fetch (or another abort-aware async operation) can watch; calling controller.abort() cancels that operation, which is the standard fix for race conditions where a stale response could otherwise overwrite a newer one.
Write an async function fetchAllOrThrow<T>(urls: string[]): Promise<T[]> that fetches every URL in parallel and parses each response as JSON typed T, throwing if any request fails, and returning all results as an array in the same order as the input.
async function fetchAllOrThrow<T>(urls: string[]): Promise<T[]> { const requests = urls.map(async (url) => { const response = await fetch(url); if (!response.ok) throw new Error(HTTP ${response.status} for ${url}); return response.json() as Promise<T>; }); return Promise.all(requests); }
Map each URL to a fetch+json promise, then pass the array of promises to Promise.all.