Async & Promises
TypeScript adds no async runtime of its own — it types exactly the model JavaScript already has: Promise, async/await, thenables, and async iterators. The whole topic comes down to one question — where the result and the failure live. A callback API puts the result in the callback's parameters and itself returns void; a Promise API puts the result in the type argument of Promise<T>. The success path the language describes precisely, and even recursively (Awaited<T> unwraps nested promises all the way down); the failure path it does not describe at all: Promise<T> says nothing about what it may reject with, so a caught rejection is any, or unknown under useUnknownInCatchVariables.
Hence the traps worth naming upfront. An async function's return type is always a Promise, and annotating it with a bare T is an error; the compiler also flattens a returned promise, so Promise<Promise<T>> never exists as a value. A callback's void return type deliberately accepts a function that returns anything — which is why arr.forEach(async x => …) compiles happily while dropping every promise on the floor. A callback declared with method-shorthand syntax checks its parameter bivariantly (loosely), while one declared as an arrow property is contravariant under strictFunctionTypes. And the naive T extends Promise<infer U> ? U : T peels only one layer and mishandles thenables — which is exactly why TS 4.5 shipped the built-in Awaited<T>. The layer-by-layer breakdown is below.
Topic map
- Typing callbacks — why the
(err, data)pair is a worse contract than a discriminated union, how contextual typing gives a callback's parameters their types for free, and why avoidreturn silently swallows promises. - Promise types —
Promise<T>as a container, the flattening in.then, how the results ofall/allSettled/race/anydiffer, and the main gap — the untyped rejection. - async/await inference — why an
asyncfunction's return is alwaysPromise<T>, how the compiler flattens a returned promise, and why athrowis invisible in the type. - The Awaited utility — the recursive conditional type, its real definition, why it beats the hand-written
T extends Promise<infer U> ? U : T, and where it lives in the standard library. - Async generators —
async function*,AsyncGenerator<T, TReturn, TNext>, theSymbol.asyncIteratorprotocol, andfor awaitfor streaming with back-pressure.
Common Mistakes and Traps
| Mistake | Consequence | |
|---|---|---|
Annotating an async function : T instead of : Promise<T> | Type error: the body returns a value, but an async function always returns a Promise | |
Expecting Promise<T> to declare its error type, as Promise<T, E> | A rejection is untyped — there is no second type argument; catch gets unknown | |
Typing a callback's err as Error | On success err is null — the contract is `(err: Error \ | null, data?: T)` |
| Thinking a callback API's result shows up in its return type | It is in the callback's parameters; the function itself returns void | |
| Declaring a callback-taking API as a method shorthand | Its callback parameter is checked bivariantly — a quiet hole; an arrow property is stricter | |
Passing an async callback to forEach | Promise<void> fits the void position, the promises are never awaited and are silently lost | |
Believing await peels only a single layer | The result type is Awaited<T>, which unwraps nested promises recursively | |
Thinking .then nests promises | then flattens thenables: Promise<Promise<T>> never exists as a value | |
Writing T extends Promise<infer U> ? U : T instead of Awaited<T> | Peels one layer, leaves Promise<string>, and does not understand an arbitrary thenable | |
Assuming Awaited<T> does something at runtime | The type is fully erased — it is type-level unwrapping, not an await | |
Storing a tuple of promises in a const variable before Promise.all | The literal widens to an array of a union — the tuple overload does not fire | |
Expecting Promise.allSettled to give the same values as Promise.all | Each element is a PromiseSettledResult<T>, discriminated on status (fulfilled/rejected) | |
Iterating an async function* with a plain for...of | Its next() gives a Promise<IteratorResult>; only for await works | |
Expecting async function* to return Promise<Generator<…>> | It returns AsyncGenerator<T, TReturn, TNext>, not a promise of a generator | |
Running an async generator through toArray/a buffer | Streaming and back-pressure are lost — the whole result is materialized again |
Interview relevance
Async comes up in every middle-level interview, and the interviewer probes not your knowledge of the words async and await but your model of the types: where the language describes the result, where the failure, and what await actually does to a type. A candidate who says "an async function's return is always Promise<T>, await gives Awaited<T> and unwraps recursively, and a rejection is typed nowhere" is immediately set apart from one who answers "await waits for a promise".
Typical checks:
- The difference between a callback API and a Promise API, and that neither types the failure.
- Why an
asyncfunction's return is alwaysPromise<T>and why you cannot annotate a bareT. - That
awaitunwraps recursively, while.thenflattens a thenable rather than nesting it. - Why
Awaited<T>exists and how it beats the hand-written conditional. - How to preserve a tuple in
Promise.all, and howallSettled/race/anydiffer in result type. - What
async function*is, how it differs from a regular generator, and how you consume it.
Common wrong answer: "Promise<T> types both success and failure — the error is the second type argument." There is no second argument: the language does not know what a promise may reject with, so catch (e) is unknown, and the ecosystem's robust answer is to return a non-throwing result type Result<T, E> as a discriminated union. The second classic failure is arr.forEach(async …): the "a void callback's return value is ignored" rule makes it valid, but forEach awaits none of the promises, the errors inside them are lost, and the loop finishes before the operations do.