TypeScript Typing async/await
async/await is JavaScript syntax for working with promises, but TypeScript adds a full type layer on top of it: every async function has a return type that TypeScript automatically wraps in Promise<T>, and every await expression has a type that TypeScript automatically unwraps from a promise. Getting this type layer right means the compiler can catch bugs like forgetting an await, mishandling a rejected promise, or misdeclaring a return type — all before your code ever runs. This lesson covers exactly how TypeScript models asynchronous code, with worked examples and the mistakes beginners run into most.
Overview / How it works
Under plain JavaScript, an async function always returns a promise, no matter what you return inside it. TypeScript enforces this at the type level: if you write async function f(): Promise<number>, the body of f must return something assignable to number (not Promise<number>) — TypeScript automatically wraps whatever you return in a promise for you. You never write return Promise.resolve(x) just to satisfy the return type; you simply return x, and the compiler understands the wrapping happens implicitly.
The reverse happens with await. When you write const x = await p, TypeScript looks at the type of p. If p has type Promise<T>, then x gets type T — the promise is "unwrapped" one layer. If p is not a promise at all (say, a plain number), await is still legal in JS and TypeScript allows it too, simply returning the same type unchanged, since await on a non-promise value resolves immediately to that value.
Internally, TypeScript uses a conditional type called Awaited<T> (built into the standard library since TypeScript 4.5) to compute this unwrapping. Awaited<T> recursively unwraps nested promises — so Awaited<Promise<Promise<string>>> resolves to string, matching how JavaScript flattens chained promise resolutions at runtime. You will see Awaited<T> show up in error messages and in advanced generic code, even if you rarely write it yourself.
It is important to remember that all of this is a compile-time-only layer. At runtime, async/await compiles down to ordinary JavaScript promises (or, on newer targets, is left as native async/await) — there is no type information left in the emitted JavaScript. The type system exists purely to catch mistakes before your code runs; it has zero effect on how fast your promises resolve or how errors propagate at runtime.
Syntax
async function functionName(param: ParamType): Promise<ReturnType> {
const result: AwaitedType = await somePromise;
return result; // must be assignable to ReturnType, NOT Promise<ReturnType>
}
- async — marks the function as asynchronous; TypeScript requires its return type (explicit or inferred) to be
Promise<T>,PromiseLike<T>, orvoid/any. - Promise<ReturnType> — the declared return type annotation; write the type of the resolved value (
ReturnType), not the promise itself — TypeScript adds thePromise<>wrapper for you. - await somePromise — pauses execution until
somePromisesettles; its type isAwaited<typeof somePromise>, i.e. the resolved value’s type with any promise wrapping removed. - return result — the value you return from the function body is checked against the unwrapped
ReturnType, not againstPromise<ReturnType>.
Examples
Example 1: A basic typed async function
interface User {
id: number;
name: string;
}
function getUserFromDb(id: number): Promise<User> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id, name: "Ada Lovelace" });
}, 10);
});
}
async function fetchUser(id: number): Promise<User> {
const user = await getUserFromDb(id);
return user;
}
async function main(): Promise<void> {
const user = await fetchUser(1);
console.log(`User: ${user.name} (id: ${user.id})`);
}
main();
Output:
User: Ada Lovelace (id: 1)
Notice that getUserFromDb explicitly returns Promise<User> using the Promise constructor, while fetchUser is declared async and also returns Promise<User> — but inside its body, user has type User (unwrapped by await), and it returns user directly rather than wrapping it again. TypeScript would reject return Promise.resolve(user) here too if the declared type were plain User, since a doubly-wrapped promise still flattens to User via Awaited, but writing it that way is redundant and non-idiomatic.
Example 2: Error handling and Promise.all
interface Product {
id: number;
price: number;
}
function getProduct(id: number): Promise<Product> {
return new Promise((resolve, reject) => {
if (id <= 0) {
reject(new Error(`Invalid product id: ${id}`));
return;
}
resolve({ id, price: id * 10 });
});
}
async function getTotalPrice(ids: number[]): Promise<number> {
try {
const products = await Promise.all(ids.map((id) => getProduct(id)));
return products.reduce((sum, p) => sum + p.price, 0);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.log(`Failed to compute total: ${message}`);
return 0;
}
}
async function main(): Promise<void> {
const total = await getTotalPrice([1, 2, 3]);
console.log(`Total: ${total}`);
const failedTotal = await getTotalPrice([1, -2, 3]);
console.log(`Total after failure: ${failedTotal}`);
}
main();
Output:
Total: 60
Failed to compute total: Invalid product id: -2
Total after failure: 0
Promise.all is typed so that Promise.all(ids.map(id => getProduct(id))), given an array of Promise<Product>, resolves to Product[] — the array wrapping is preserved while each element’s promise is unwrapped. Note also the catch block: TypeScript types the caught value err as unknown by default (under strict mode via useUnknownInCatchVariables), which is why you must narrow it with err instanceof Error before accessing .message — this forces you to handle the fact that JavaScript allows throwing values of any type, not just Error objects.
Example 3: Generic async functions and Awaited<T>
async function withRetry<T>(
task: () => Promise<T>,
retries: number
): Promise<T> {
try {
return await task();
} catch (err) {
if (retries <= 0) {
throw err;
}
console.log(`Retrying... (${retries} left)`);
return withRetry(task, retries - 1);
}
}
let attempts = 0;
function flakyTask(): Promise<string> {
attempts++;
if (attempts < 3) {
return Promise.reject(new Error(`attempt ${attempts} failed`));
}
return Promise.resolve("success");
}
type FlakyResult = Awaited<ReturnType<typeof flakyTask>>;
async function main(): Promise<void> {
const result: FlakyResult = await withRetry(flakyTask, 5);
console.log(`Result: ${result}`);
}
main();
Output:
Retrying... (5 left)
Retrying... (4 left)
Result: success
withRetry is generic over T, so it works for any task that returns a promise, and TypeScript infers T from the task argument at each call site. FlakyResult demonstrates composing utility types: ReturnType<typeof flakyTask> extracts Promise<string> from the function’s signature, and wrapping that in Awaited<> unwraps it down to plain string — the same unwrapping await performs automatically, but usable in type positions where you don’t have an actual expression to await.
How it works step by step / Under the hood
- When you declare
async function f(): Promise<T>, TypeScript checks everyreturnstatement in the body againstT, not againstPromise<T>— the wrapping is implicit. - When you omit the return type, TypeScript infers it: it looks at the type of everything returned, and infers
Promise<InferredType>for the function as a whole. - At every
await expr, TypeScript computesAwaited<typeof expr>: ifexpr‘s type isPromise<X>(or any "thenable" with a compatible.thenmethod), the result type isX; nested promises are unwrapped recursively; non-promise types pass through unchanged. - A thrown error inside an
asyncfunction does not change the function’s declared return type — TypeScript does not track thrown exceptions in types (there is no "throws" annotation), so callers must usetry/catchdefensively rather than relying on the type system to flag unhandled errors. - At compile time,
tscemits plain JavaScript: on modern targets (ES2017+)async/awaitis left as-is since the runtime understands it natively; on older targets it may be downleveled to generator-based state machines. Either way, all type annotations are stripped — the compiled JS has no notion ofPromise<T>versusPromise<U>, only ordinaryPromiseobjects.
Common Mistakes
Mistake 1: Forgetting await
Forgetting to await an async call leaves you holding a Promise<T> instead of a T, and TypeScript will catch the misuse:
async function getCount(): Promise<number> {
return 42;
}
async function main(): Promise<void> {
const count = getCount(); // missing await
console.log(count.toFixed(2)); // Error: Property 'toFixed' does not exist on type 'Promise<number>'.
}
tsc reports: Property 'toFixed' does not exist on type 'Promise<number>'. — because count is a promise, not a number, until you await it. The fix is simple:
async function getCount(): Promise<number> {
return 42;
}
async function main(): Promise<void> {
const count = await getCount();
console.log(count.toFixed(2));
}
main();
Output:
42.00
Mistake 2: Declaring the return type without Promise<>
A common slip is to annotate an async function’s return type as the plain resolved type, forgetting the Promise wrapper:
async function getName(): string {
return "Grace Hopper";
}
tsc reports: The return type of an async function or method must be the global Promise<T> type. Since async functions always return a promise at runtime, TypeScript requires the declared return type to reflect that reality. The fix is to wrap the annotation in Promise<>:
async function getName(): Promise<string> {
return "Grace Hopper";
}
async function main(): Promise<void> {
const name = await getName();
console.log(name);
}
main();
Output:
Grace Hopper
Best Practices
- Always annotate the resolved type in
Promise<T>, notTalone, onasyncfunction signatures — it documents intent and lets TypeScript catch mismatchedreturnstatements early. - Prefer
Promise.allfor independent async operations that can run concurrently, rather thanawait-ing them one at a time in sequence — TypeScript preserves per-element types through the array. - Use
Promise.allSettledinstead ofPromise.allwhen you need every operation’s outcome even if some reject; its resolved type is an array of{ status: "fulfilled", value: T } | { status: "rejected", reason: unknown }objects, which you must narrow before using. - Treat the
catchvariable asunknown(thestrict-mode default) and narrow it withinstanceof Errorbefore reading properties like.message. - Reach for the built-in
Awaited<T>utility type when you need to describe "the resolved type of this promise" in a type position, rather than re-deriving it manually. - Remember that
asyncfunctions never throw synchronously — even athrowon the first line becomes a rejected promise, so always handle errors with.catch()ortry/catch, never a synchronoustryaround the call itself.
Practice Exercises
- Exercise 1: Write an
asyncfunctiondouble(n: number): Promise<number>that waits 5ms (via a promise-wrappedsetTimeout) and then resolves withn * 2. Call it withawaitinside anotherasyncfunction and log the result fordouble(21). Expected output:42. - Exercise 2: Write a generic function
timeout<T>(promise: Promise<T>, ms: number): Promise<T>that resolves/rejects with whateverpromisedoes, but rejects with anErrorifmsmilliseconds pass first (hint: usePromise.race). Make sure the return type staysPromise<T>for anyTyou pass in. - Exercise 3: Given
function loadConfig(): Promise<{ debug: boolean }>, write a type aliasConfigthat equals the resolved type ofloadConfig‘s return value, usingAwaited<ReturnType<typeof loadConfig>>, without calling the function.
Summary
asyncfunctions must declare (or infer) a return type ofPromise<T>; youreturn Tdirectly and TypeScript wraps it.await exprunwraps a promise type down to its resolved value using the built-inAwaited<T>conditional type, recursively flattening nested promises.Promise.allpreserves per-element types through an array;Promise.allSettledgives you a discriminated union describing success or failure per item.- The caught value in a
catchblock is typedunknownunderstrictmode — narrow it withinstanceof Errorbefore use. - All of this typing is compile-time only; the emitted JavaScript is ordinary promise-based code with zero runtime type information.
