Typing async results and failures
What Promise<T> does and does not say about failure, narrowing allSettled results, return versus return await, and errors as values.
After this lesson you can
- Say why Promise<T> carries no type for its rejection
- Narrow the results of Promise.allSettled on status
- Explain when return await matters inside try/catch
- Model expected failures as a typed result instead of a throw
Promise<T> describes the value a promise fulfils with. It says nothing
about how it can fail: there is no second type parameter for the
rejection, because any code along the way can throw anything. That is the
same reason a caught error is unknown.
The types around promises worth knowing:
- An
asyncfunction's return type is alwaysPromise<...>; returning aTproducesPromise<T>, and returning aPromise<T>does not nest. Awaited<T>is whatawaitproduces, unwrapping nested promises:Awaited<Promise<Promise<number>>>isnumber.PromiseSettledResult<T>is a discriminated union:{ status: "fulfilled"; value: T } | { status: "rejected"; reason: any }.
allSettled, narrowed
Promise.all gives you every value or the first failure.
Promise.allSettled waits for everything and reports each outcome. Each
result is narrowed on status before you may read value or reason —
and reason is typed any, so treat it as unknown:
function charge(amount: number): Promise<string> { return new Promise((resolve, reject) => setTimeout(() => { if (amount <= 0) reject(new Error(`invalid amount ${amount}`)); else resolve(`charged ${amount}`); }, 10), );} export async function run(): Promise<string[]> { const results = await Promise.allSettled([charge(20), charge(-5), charge(8)]); // Each result is PromiseSettledResult<string>: narrow on status to read it. return results.map((r) => r.status === "fulfilled" ? `ok: ${r.value}` : `failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`, );}The results come back in the order of the input array, whatever order the promises settled in.
return against return await
Inside a try, return somePromise hands the promise to the caller
before it settles; the function leaves the try, so a later rejection
is never seen by its catch. return await settles it inside the try:
async function load(): Promise<string> { throw new Error("database down");} async function withoutAwait(): Promise<string> { try { return load(); // the promise leaves the try before it rejects } catch { return "fallback"; }} async function withAwait(): Promise<string> { try { return await load(); // the rejection is thrown here, inside the try } catch { return "fallback"; }} export async function run(): Promise<string[]> { const out: string[] = []; for (const f of [withoutAwait, withAwait]) { try { out.push(`${f.name}: ${await f()}`); } catch (e) { out.push(`${f.name}: rejected with ${(e as Error).message}`); } } return out;}Outside a try, the caller sees the same outcome either way; the
difference is only which function the rejection passes through.
Expected failures as values
A rejection is invisible in the type. For failures the caller is expected to handle — not found, declined, suspended — returning a discriminated union puts them in the type, and the compiler then makes every caller look:
type Result<T, E extends string> = | { ok: true; value: T } | { ok: false; error: E }; type LookupError = "not-found" | "suspended"; async function findUser(id: number): Promise<Result<string, LookupError>> { await new Promise((r) => setTimeout(r, 5)); if (id === 1) return { ok: true, value: "Nino" }; if (id === 2) return { ok: false, error: "suspended" }; return { ok: false, error: "not-found" };} export async function run(): Promise<string[]> { const out: string[] = []; for (const id of [1, 2, 3]) { const r = await findUser(id); // The failure is part of the type, so the caller cannot forget it exists. out.push(r.ok ? `found ${r.value}` : `no user ${id}: ${r.error}`); } return out;}Keep throwing for the unexpected (a bug, a lost connection); return a result for outcomes that are part of the domain.
Common mistakes
- Assuming
Promise<User>documents the failures. It does not; read the code, or use a result type. return promiseinsidetryand expectingcatchto handle it. As the example shows, the rejection goes to the caller instead.- Reading
r.valueon anallSettledresult without narrowing. A rejected entry has novalue; checkstatusfirst. - Collecting results in a
.thencallback. Callbacks run in completion order, not input order; take the array thatallorallSettledreturns instead.
What interviewers ask
- Why is there no
Promise<T, E>? Anything can throw inside the chain, and the type system does not track exceptions, so a rejection type could never be trusted; the reason is effectivelyunknown. - When does
return awaitmatter? Inside (or ), where the rejection must happen while the function is still in the block.
Exercise · Pro
Fall back from one source to the next
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade