Typing Async Code
Almost every real TypeScript program waits for something: an API response, a database query, a file. JavaScript models waiting with promises and async/await (see Async/Await in the JavaScript course). TypeScript adds one important idea on top: a promise has a type parameter, Promise<T>, which says what you will get when you await it.
In this lesson you will type async functions, combine several promises without losing type information, add timeouts, stream data with async generators, and see the async mistakes TypeScript catches for you, plus the ones it cannot.
Async functions return Promise of T
An async function always returns a promise, even when you return a plain value. If you annotate the return type, write Promise<T>, not T:
type Order = { id: string; total: number };
async function loadOrder(id: string): Order {
return { id, total: 1299 };
}app.ts(3,39): error TS1064: The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<Order>'?
With Promise<Order> as the return type, TypeScript checks every return inside the function against Order, and every caller that writes await loadOrder("A1") gets an Order. If you leave the annotation out, TypeScript infers it, but annotating exported functions keeps their contract stable when the body changes.
Here is a small fake API used in the rest of this lesson. The delay helper is generic, so delay(50, value) resolves to the same type as value:
type Student = { rollNo: number; name: string };
type Fees = { rollNo: number; due: number };
function delay<T>(ms: number, value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function getStudent(rollNo: number): Promise<Student> {
return delay(30, { rollNo, name: "Aarav" });
}
async function getFees(rollNo: number): Promise<Fees> {
return delay(20, { rollNo, due: 4500 });
}
const [student, fees] = await Promise.all([getStudent(7), getFees(7)]);
console.log(`${student.name} owes ₹${fees.due}`);Aarav owes ₹4500
Promise.all keeps each position's type: student is a Student and fees is a Fees, not a vague (Student | Fees)[]. The two requests also run at the same time, so the total wait is about 30 ms instead of 50 ms.
Promise.allSettled and discriminated results
Promise.all rejects as soon as one promise fails. When you want every result, successful or not, use Promise.allSettled. Each result is a discriminated union (see Discriminated Unions at Scale) with the tag status:
function delay<T>(ms: number, value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function smsBalance(gateway: string): Promise<number> {
if (gateway === "backup") throw new Error("backup gateway is down");
return delay(10, gateway === "main" ? 1200 : 300);
}
const gateways = ["main", "backup", "promo"];
const results = await Promise.allSettled(gateways.map(smsBalance));
results.forEach((r, i) => {
if (r.status === "fulfilled") {
console.log(`${gateways[i]}: ${r.value} SMS left`);
} else {
const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
console.log(`${gateways[i]}: failed (${msg})`);
}
});main: 1200 SMS left backup: failed (backup gateway is down) promo: 300 SMS left
Inside the "fulfilled" branch, r.value is a number. Inside the "rejected" branch you get r.reason, which is typed any because anything can be thrown. Treat it like unknown and check it, as shown, before reading .message. You will learn more about this in Error Handling Patterns.
Timeouts and cancellation with AbortSignal
Network calls can hang. The standard way to cancel async work in browsers and Node.js is an AbortSignal. AbortSignal.timeout(ms) creates a signal that aborts by itself after the given time. Accepting an optional signal parameter is a good habit for any async function you write:
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}
async function slowReport(signal?: AbortSignal): Promise<string> {
await wait(200, signal);
return "Monthly sales report ready";
}
try {
console.log(await slowReport(AbortSignal.timeout(500)));
console.log(await slowReport(AbortSignal.timeout(50)));
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
console.log("Gave up after 50 ms");
} else {
throw err;
}
}Monthly sales report ready Gave up after 50 ms
With a real fetch, you simply pass the signal along: fetch(url, { signal }). A signal that times out rejects with a DOMException named "TimeoutError"; one aborted by your own AbortController uses the name "AbortError".
Bugs TypeScript catches, and one it cannot
The classic async bug is a forgotten await. TypeScript catches it whenever the missing await makes a type wrong:
declare function isAdmin(userId: string): Promise<boolean>;
declare function getMarks(rollNo: number): Promise<number[]>;
async function check() {
if (isAdmin("u7")) {
console.log("admin");
}
const marks = getMarks(3);
console.log(marks.length);
}app.ts(5,7): error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined. app.ts(9,21): error TS2339: Property 'length' does not exist on type 'Promise<number[]>'.
But a promise that nobody uses at all, such as a bare saveOrder(order); line, is valid TypeScript. If it fails, the error is lost (an "unhandled rejection"). The compiler cannot know whether you meant to wait. The no-floating-promises rule from Linting with typescript-eslint catches these lines, and most teams turn it on.
A related trap: forEach ignores the promises your callback returns. Use for...of with await for one-by-one work, or Promise.all with map for parallel work:
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
async function sendReminder(name: string): Promise<void> {
await delay(10);
console.log(`Reminder sent to ${name}`);
}
const names = ["Priya", "Rohan"];
names.forEach(async (n) => await sendReminder(n)); // does not wait
console.log("forEach finished?");
await delay(50);
for (const n of names) {
await sendReminder(n); // waits for each one
}
console.log("for...of finished");forEach finished? Reminder sent to Priya Reminder sent to Rohan Reminder sent to Priya Reminder sent to Rohan for...of finished
Async generators
An async function* produces values over time, such as pages from a paginated API. Its return type is AsyncGenerator<T>, and you consume it with for await...of:
type Page = { page: number; items: string[] };
async function* fetchPages(total: number): AsyncGenerator<Page> {
for (let page = 1; page <= total; page++) {
await new Promise((r) => setTimeout(r, 5));
yield { page, items: [`order-${page}a`, `order-${page}b`] };
}
}
let count = 0;
for await (const p of fetchPages(3)) {
count += p.items.length;
console.log(`page ${p.page}: ${p.items.join(", ")}`);
}
console.log(`${count} orders`);
const all = await Array.fromAsync(fetchPages(2));
console.log(all.map((p) => p.page));page 1: order-1a, order-1b page 2: order-2a, order-2b page 3: order-3a, order-3b 6 orders [ 1, 2 ]
Array.fromAsync (ES2024) collects everything an async iterable produces into an array, and its result type is Page[].
Common mistakes
- Annotating an async function with
Tinstead ofPromise<T>. The compiler reports TS1064. - Calling an async function without
awaitor.catch(). Errors vanish. Turn onno-floating-promises. - Using
forEachwith an async callback. It never waits. Usefor...oforPromise.all. - Reading
reason.messagedirectly.reasonandcatchvariables can hold anything; check withinstanceof Errorfirst. - Awaiting independent requests one after another. Start them together with
Promise.all.
Key points
- An async function's return type is
Promise<T>;awaitturnsPromise<T>back intoT. Promise.allkeeps the type of each position in a tuple;Promise.allSettledgives results tagged withstatus.- Accept an optional
AbortSignalin async functions;AbortSignal.timeout(ms)adds a deadline. - TypeScript catches many forgotten
awaits through type errors, but not unused promises; a linter rule covers those. async function*returnsAsyncGenerator<T>, consumed withfor await...oforArray.fromAsync.
Exercise
Write fetchWithRetry<T>(task: () => Promise<T>, attempts: number): Promise<T> that calls task, and if it throws, tries again until attempts runs out, then rethrows the last error. Test it with a fake "IRCTC status" task that fails twice and then returns "Confirmed", and log how many calls were made.
Show solution
async function fetchWithRetry<T>(task: () => Promise<T>, attempts: number): Promise<T> {
let lastError: unknown;
for (let i = 1; i <= attempts; i++) {
try {
return await task();
} catch (err) {
lastError = err;
}
}
throw lastError;
}
let calls = 0;
async function pnrStatus(): Promise<string> {
calls++;
if (calls < 3) throw new Error("server busy");
return "Confirmed";
}
console.log(await fetchWithRetry(pnrStatus, 5)); // Confirmed
console.log(`calls: ${calls}`); // calls: 3T is inferred from task, so the result is a string here. return await inside try matters: without await, a rejected promise would skip the catch block.
Quick Quiz
1. What return type should async function total(): ... have if it returns a number?
Async functions always return a promise of the returned value.
2. What is the type of fees in const [s, fees] = await Promise.all([getStudent(1), getFees(1)])?
Promise.all keeps the type of each position in the tuple.
3. Which bug does TypeScript NOT report by itself?
An unused promise is valid code; the no-floating-promises lint rule catches it.
4. In Promise.allSettled results, how do you safely read the value?
The results are a discriminated union tagged by status.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
