EthicsComputerCourses
Join training +91 90532 10052

Typing Async Code

TypeScript · Lesson 28 of 42 Advanced 7 min read Updated

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:

TypeScript
type Order = { id: string; total: number };

async function loadOrder(id: string): Order {
  return { id, total: 1299 };
}
Output
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:

TypeScriptRun it yourself
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}`);
Output
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:

TypeScriptRun it yourself
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})`);
  }
});
Output
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:

TypeScriptRun it yourself
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;
  }
}
Output
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:

TypeScript
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);
}
Output
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:

TypeScriptRun it yourself
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");
Output
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:

TypeScriptRun it yourself
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));
Output
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 T instead of Promise<T>. The compiler reports TS1064.
  • Calling an async function without await or .catch(). Errors vanish. Turn on no-floating-promises.
  • Using forEach with an async callback. It never waits. Use for...of or Promise.all.
  • Reading reason.message directly. reason and catch variables can hold anything; check with instanceof Error first.
  • Awaiting independent requests one after another. Start them together with Promise.all.

Key points

  • An async function's return type is Promise<T>; await turns Promise<T> back into T.
  • Promise.all keeps the type of each position in a tuple; Promise.allSettled gives results tagged with status.
  • Accept an optional AbortSignal in 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* returns AsyncGenerator<T>, consumed with for await...of or Array.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
TypeScript
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: 3

T 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?

2. What is the type of fees in const [s, fees] = await Promise.all([getStudent(1), getFees(1)])?

3. Which bug does TypeScript NOT report by itself?

4. In Promise.allSettled results, how do you safely read the value?

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