Phase 6 · Utility types in practice · Lesson 6.4
AdvancedAsync code and errors
Typing promises and async functions, Promise.all and friends as tuples, unknown catch variables, custom Error classes, a Result type, and generic retry and timeout helpers.
25 min
Async code is where types quietly get weaker. A .catch callback receives any. Promise.all on the wrong kind of array loses its tuple. An error thrown three calls deep has no trace in any signature. None of this is caught by default, and all of it comes up in interviews. This lesson makes async types as precise as the rest of your code, and shows how to make failure part of the type when it matters.
Promises and async functions
An async function always returns a promise. If you annotate its return type, it must be Promise<Something>:
type User = { id: number; name: string };
async function getUser(id: number): Promise<User> {
return { id, name: "Ada" }; // you return a User; the function returns Promise<User>
}
// @ts-expect-error -- The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<User>'?
async function broken(id: number): User {
return { id, name: "Ada" };
}
const pending = getUser(1);
// ^? const pending: Promise<User>Inside an async function, return of a promise is flattened: returning a Promise<User> from an async function gives Promise<User>, never Promise<Promise<User>>. Promises can't nest at runtime, and the types model that.
await goes the other way. The type of await x is Awaited<typeof x>, which unwraps promises recursively and leaves other values alone:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
async function load() {
return [1, 2, 3];
}
type Loaded = Awaited<ReturnType<typeof load>>;
type _t1 = Expect<Equal<Loaded, number[]>>;
async function main() {
const numbers = await load();
// ^? const numbers: number[]
const same = await 42; // awaiting a non-promise is allowed
// ^? const same: 42
}Promise.all, allSettled, race and any
Promise.all is typed with a mapped tuple type (roughly { -readonly [P in keyof T]: Awaited<T[P]> }), so when you pass an array literal, each position keeps its own type:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type User = { id: number; name: string };
declare function getUser(id: number): Promise<User>;
declare function getUnreadCount(): Promise<number>;
async function dashboard() {
const [user, unread] = await Promise.all([getUser(1), getUnreadCount()]);
type _t1 = Expect<Equal<typeof user, User>>;
type _t2 = Expect<Equal<typeof unread, number>>;
}The gotcha: build the array first and the tuple is gone. A variable holding [a, b] is inferred as an array of a union, so the result is an array of a union too.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type User = { id: number; name: string };
declare function getUser(id: number): Promise<User>;
declare function getUnreadCount(): Promise<number>;
async function dashboard() {
const tasks = [getUser(1), getUnreadCount()];
// ^? const tasks: (Promise<User> | Promise<number>)[]
const results = await Promise.all(tasks);
type _t1 = Expect<Equal<typeof results, (User | number)[]>>;
const tupleTasks = [getUser(1), getUnreadCount()] as const; // keep it a tuple
const [user, unread] = await Promise.all(tupleTasks);
type _t2 = Expect<Equal<typeof user, User>>;
}The other combinators:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type User = { id: number; name: string };
declare function getUser(id: number): Promise<User>;
declare function getUnreadCount(): Promise<number>;
async function examples() {
// allSettled never rejects: every slot is a result object
const settled = await Promise.allSettled([getUser(1), getUnreadCount()]);
type _t1 = Expect<Equal<typeof settled, [PromiseSettledResult<User>, PromiseSettledResult<number>]>>;
const first = settled[0];
if (first.status === "fulfilled") {
first.value.name; // narrowed: PromiseFulfilledResult<User>
} else {
first.reason; // PromiseRejectedResult: reason is any!
}
// race and any: whichever settles / fulfils first, so a union
const fastest = await Promise.race([getUser(1), getUnreadCount()]);
type _t2 = Expect<Equal<typeof fastest, User | number>>;
const firstOk = await Promise.any([getUser(1), getUnreadCount()]);
type _t3 = Expect<Equal<typeof firstOk, User | number>>;
}| Combinator | Settles when | Result type for [Promise<A>, Promise<B>] | Rejects with |
|---|---|---|---|
Promise.all | all fulfil, or one rejects | [A, B] | the first rejection |
Promise.allSettled | all settle | [PromiseSettledResult<A>, PromiseSettledResult<B>] | never rejects |
Promise.race | the first settles | A | B | the first rejection, if it's first |
Promise.any | the first fulfils | A | B | AggregateError if all reject |
Quick check
What is the type of results?
async function run(a: Promise<string>, b: Promise<number>) {
const jobs = [a, b];
const results = await Promise.all(jobs);
}Catch variables are unknown: narrow them
JavaScript can throw any value, so under strict (useUnknownInCatchVariables) a catch variable is unknown. You have to narrow before using it:
function describeError(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return `Unknown error: ${JSON.stringify(error)}`;
}
try {
JSON.parse("{ oops");
} catch (error) {
console.error(describeError(error));
}A common helper normalises anything thrown into a real Error, so the rest of the code deals with one type:
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : JSON.stringify(value));
}Custom Error subclasses
Subclassing Error lets callers tell failures apart with instanceof, and carry extra data:
class NotFoundError extends Error {
override readonly name = "NotFoundError";
constructor(readonly resource: string, readonly id: string, options?: ErrorOptions) {
super(`${resource} ${id} not found`, options);
}
}
class HttpError extends Error {
override readonly name = "HttpError";
constructor(readonly status: number, options?: ErrorOptions) {
super(`HTTP ${status}`, options);
}
}
function handle(error: unknown) {
if (error instanceof NotFoundError) return `Missing ${error.resource}`; // error.resource is typed
if (error instanceof HttpError && error.status >= 500) return "Server problem, try again";
throw error; // not ours: rethrow
}Details worth knowing:
name: set it so logs anderror.toString()show the class. Declaring it as areadonlyliteral also makes the classes structurally distinct.cause(ES2022): the second constructor argument,{ cause }, chains the original error. Its type isunknown, for the same reason catch variables are.options?: ErrorOptions: forward it tosuperso callers can pass acause.ErrorOptionscomes fromlib.es2022.error.d.ts.- Old targets: when compiling to ES5,
extends Errorbreaksinstanceof(the prototype chain isn't set up). The fix wasObject.setPrototypeOf(this, new.target.prototype)in the constructor. WithtargetES2015 or later, native classes make it unnecessary.
class HttpError extends Error {
override readonly name = "HttpError";
constructor(readonly status: number, options?: ErrorOptions) {
super(`HTTP ${status}`, options);
}
}
async function getJson(url: string): Promise<unknown> {
try {
const res = await fetch(url);
if (!res.ok) throw new HttpError(res.status);
return await res.json();
} catch (error) {
throw new Error(`Failed to load ${url}`, { cause: error }); // keep the original
}
}Making failure visible: a Result type
Exceptions are invisible in signatures: getUser(id: number): Promise<User> says nothing about "not found". For expected failures (validation, not found, permission denied) a common alternative is to return the failure as a value:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
type User = { id: number; name: string };
type LookupError = { kind: "not-found"; id: number } | { kind: "banned"; reason: string };
function findUser(id: number): Result<User, LookupError> {
if (id === 0) return err({ kind: "not-found", id });
if (id < 0) return err({ kind: "banned", reason: "spam" });
return ok({ id, name: "Ada" });
}
const result = findUser(1);
if (result.ok) {
result.value.name; // narrowed to the success branch
} else if (result.error.kind === "banned") {
result.error.reason; // narrowed again, on the error union
}
// @ts-expect-error -- Property 'value' does not exist on type '{ ok: false; error: LookupError; }'.
result.value;Result is a discriminated union on ok, so the compiler forces callers to check before touching value. And the error type is spelled out, so a new LookupError kind shows up at every switch that handles them. Result<T, never> for ok works because never is assignable to every type, so it fits any Result<T, E>.
A tryCatch helper
To bridge code that throws into code that returns Result, wrap it once:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
async function tryCatch<T>(work: () => Promise<T>): Promise<Result<T, Error>> {
try {
return { ok: true, value: await work() }; // `await` matters: see below
} catch (error) {
return { ok: false, error: toError(error) };
}
}
async function main() {
const result = await tryCatch(() => fetch("/api/user").then((res) => res.json() as Promise<unknown>));
// ^? const result: Result<unknown, Error>
if (!result.ok) {
console.error(result.error.message);
return;
}
console.log(result.value);
}▶ Try it in the TypeScript Playground
The await inside try is essential. return work() without await hands the promise back unsettled; if it rejects later, the rejection skips this catch entirely. Inside try/catch, always return await.
Generic retry and timeout helpers
Both are standard interview exercises: "wrap any async function, keep its type". The generic T flows from the argument to the result.
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
type RetryOptions = { retries?: number; delayMs?: number };
async function retry<T>(
fn: (attempt: number) => Promise<T>,
{ retries = 3, delayMs = 200 }: RetryOptions = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt < retries) await sleep(delayMs * 2 ** attempt); // exponential backoff
}
}
throw new Error(`Failed after ${retries + 1} attempts`, { cause: lastError });
}
class TimeoutError extends Error {
override readonly name = "TimeoutError";
constructor(readonly ms: number) {
super(`Timed out after ${ms} ms`);
}
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new TimeoutError(ms)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function main() {
const user = await retry(() => withTimeout(fetch("/api/user").then((r) => r.json() as Promise<{ name: string }>), 2000));
// ^? const user: { name: string }
console.log(user.name);
}The type-level pieces:
retry<T>:Tis inferred from the callback's return type, so the caller getsPromise<{ name: string }>without writing any type arguments.Promise<never>for the timeout: it can only reject.raceofPromise<T>andPromise<never>isT | never, which is justT.ReturnType<typeof setTimeout>: portable timer type, whethersetTimeoutreturns anumber(browser) or an object (Node).clearTimeoutinfinally: otherwise the timer keeps running (and can keep a process alive) after the work finishes.lastError: unknown: it's whatever was thrown; it goes intocauseunchanged.
Quick check
What is the return type of withTimeout(fetchCount(), 500) if fetchCount returns Promise<number>?
Why there's no throws clause
Java has checked exceptions (throws IOException). TypeScript deliberately doesn't, and interviewers like to ask why:
- Anything can throw. Property access on
undefined, a stack overflow, a getter, any library call. Athrowsclause would either be a lie or appear on everything. - JavaScript can throw any value, not just
Errorsubclasses, so the "type" of a throw is essentiallyunknownanyway. - Higher-order functions make it unworkable.
array.map(fn)throws whateverfnthrows; typing that everywhere would require effect generics across the whole standard library. - Experience from Java: checked exceptions are widely seen as noisy, and people wrap them into unchecked ones to get rid of them.
What to do instead:
| Situation | Approach |
|---|---|
| Expected, recoverable failure (not found, invalid input) | Return a Result / discriminated union so the caller must handle it |
| Bug or unrecoverable failure | Throw an Error (subclass); catch high up, log, report |
| Function that never returns normally | Return type never (e.g. function fail(msg: string): never) |
| Documenting what may be thrown | /** @throws {NotFoundError} ... */ in JSDoc: shown in editors, not enforced |
Spot the error
This helper is meant to return null when the request fails. It doesn't: rejections escape it. Why? (It type-checks fine.)
async function safeFetchJson(url: string): Promise<unknown> {
try {
return fetch(url).then((res) => res.json());
} catch {
return null;
}
}Show the fix
The try block returns the promise without awaiting it. The function exits the try before the promise settles, so a later rejection never reaches this catch; it rejects the returned promise instead. Types can't catch this, because Promise<unknown> is correct either way. Add await:
async function safeFetchJson(url: string): Promise<unknown> {
try {
const res = await fetch(url);
return await res.json();
} catch {
return null;
}
}Linters have a rule for this (return-await in try blocks). Also note catch { without a variable (ES2019 optional catch binding) is fine when you don't need the error.
Recap
- An
asyncfunction always returnsPromise<T>; annotate it as such.await xhas typeAwaited<typeof x>. Promise.all/allSettledpreserve tuples only for array literals (oras const);raceandanygive a union.allSettledresults narrow onstatus;reasonisany, as is the.catchcallback's parameter.catch (e)isunknownunderstrict: narrow withinstanceof Error, or normalise with atoErrorhelper.- Subclass
Errorwith a literalname, forwardErrorOptions, and chain with{ cause }. Result<T, E>makes expected failures part of the type;tryCatchbridges throwing code into it (withreturn await).- Generic helpers like
retry<T>andwithTimeout<T>inferTfrom the wrapped call;Promise<never>models a promise that only rejects. - There's no
throwsclause: useResultfor expected errors, exceptions for bugs,neverfor functions that can't return.
Interview cards
1 / 8