Types meet the runtime Foundation
Async TypeScript
Type promises and async functions precisely, keep Promise.all tuples intact, narrow allSettled results, use Awaited, and page through an API with typed async generators.
BY THE END OF THIS LESSON YOU CAN
- Read and write the types of async functions, and know why their return type is always a Promise
- Keep Promise.all results as a typed tuple, and spot when they collapse into an array of unions
- Narrow allSettled results and write generic helpers with Awaited and Promise of never
- Find the async mistakes the compiler cannot see, such as async callbacks passed to filter and forEach
- Type async iterables and generators, and page through an API lazily
The payout filter that let everything through
A payouts job runs every evening. It sends each seller's money unless a fraud check flags the payout, in which case the payout is held for review. The fraud check calls another service, so it is async:
interface Payout {
id: string;
sellerId: string;
kobo: number;
}
async function isSuspicious(payout: Payout): Promise<boolean> {
await new Promise((resolve) => setTimeout(resolve, 5));
return payout.kobo > 10_000_000;
}
const payouts: Payout[] = [
{ id: "PO-1", sellerId: "S-1", kobo: 450_000 },
{ id: "PO-2", sellerId: "S-2", kobo: 25_000_000 },
{ id: "PO-3", sellerId: "S-3", kobo: 90_000 },
];
const toReview = payouts.filter(async (payout) => await isSuspicious(payout));
const toPay = payouts.filter(async (payout) => !(await isSuspicious(payout)));
console.log("review:", toReview.map((payout) => payout.id));
console.log("pay:", toPay.map((payout) => payout.id));
npx tsx payouts.ts and of the browser terminalreview: [ 'PO-1', 'PO-2', 'PO-3' ] pay: [ 'PO-1', 'PO-2', 'PO-3' ]
Every payout is paid and held for review, including the ₦250,000 one the check was written to stop. filter expects its callback to return a boolean-ish value right now. An async callback returns a Promise, and a promise object is always truthy, so every item is kept. And it compiled without a word, because filter's callback type allows any return value.
The same mistake in an if is caught:
async function isSuspicious(kobo: number): Promise<boolean> {
return kobo > 10_000_000;
}
async function releasePayout(kobo: number): Promise<string> {
if (isSuspicious(kobo)) return "held for review";
return "paid";
}
npx tsc --noEmit printsforgot-await.ts:6:7 - error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined.
6 if (isSuspicious(kobo)) return "held for review";
~~~~~~~~~~~~~~~~~~
forgot-await.ts:6:7 - Did you forget to use 'await'?
6 if (isSuspicious(kobo)) return "held for review";
~~~~~~~~~~~~~~~~~~
Found 1 error in forgot-await.ts:6That is async TypeScript in a nutshell. Promises have precise types, and the compiler uses them well in most places, but there are gaps: callbacks whose return value is ignored, values typed any, arrays that lose their shape. This lesson shows the types behind async code so you can tell which situation you are in. The runtime behaviour of promises, combinators and async generators is covered in Promises in depth, Promise combinators and Generators; here you add the types.
Promise<T> and async functions
Promise<T> is a generic interface: a promise that, when it fulfils, gives a value of type T. An async function always returns a promise, whatever its body returns, so its return type is always Promise<…>. When you leave it out, TypeScript infers it from the return statements.
To show inferred types without an editor, this lesson uses two tiny type-level test helpers. Equal<A, B> is true when two types are identical, and Expect only accepts true, so a line like type t = Expect<Equal<X, number>> compiles only if X really is number. How Equal works is a puzzle for Conditional types; for now, use it:
export type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
export type Expect<T extends true> = T;
import type { Equal, Expect } from "./type-tests.js";
async function getBalance(accountId: string) {
if (accountId === "") throw new Error("account id required");
return 250_000;
}
async function getAccount(accountId: string) {
return { accountId, balanceKobo: await getBalance(accountId) };
}
async function recordAudit(message: string) {
console.log(`audit: ${message}`);
}
type T1 = Expect<Equal<ReturnType<typeof getBalance>, Promise<number>>>;
type T2 = Expect<Equal<ReturnType<typeof getAccount>, Promise<{ accountId: string; balanceKobo: number }>>>;
type T3 = Expect<Equal<ReturnType<typeof recordAudit>, Promise<void>>>;
console.log(await getAccount("ACC-1"));
await recordAudit("balance read");
npx tsx inferred.ts and of the browser terminal{ accountId: 'ACC-1', balanceKobo: 250000 }
audit: balance readThis file compiling is the test: if any of the three types were different, tsc would fail on its line. getBalance returns a number, so it is Promise<number>. A function that returns nothing is Promise<void>. Inside getAccount, await getBalance(…) has type number: await removes one Promise layer at the type level exactly as it does at runtime.
Annotating async functions
For exported functions, write the return type (Inference explains why public boundaries deserve annotations). The annotation must be Promise<T>, and the body is then checked against T:
export async function getBalance(accountId: string): number {
return 250_000;
}
export async function getRate(currency: string): Promise<number> {
return currency === "USD" ? "1540.25" : 1;
}
npx tsc --noEmit printsannotated.ts:1:54 - error TS1064: The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<number>'?
1 export async function getBalance(accountId: string): number {
~~~~~~
annotated.ts:6:31 - error TS2322: Type 'string' is not assignable to type 'number'.
6 return currency === "USD" ? "1540.25" : 1;
~~~~~~~~~
Found 2 errors in the same file, starting at: annotated.ts:1In the second function, return is checked against number, not Promise<number>: inside an async function you return the plain value, and the function wraps it. Returning another promise is also fine, because promises flatten: return getBalance(id) in a function typed Promise<number> compiles, and the caller still gets a number after one await. A Promise<Promise<number>> cannot exist at runtime.
new Promise<T> and wrapping callbacks
Older libraries report results through callbacks. To use them with await, you wrap them in new Promise. The constructor is generic, new Promise<T>(executor), and T is the type resolve accepts. If nothing tells TypeScript what T is, it becomes unknown:
const rate = new Promise((resolve) => setTimeout(() => resolve(1540.25), 10));
const kobo = (await rate) * 100;
npx tsc --noEmit printsrate.ts:2:14 - error TS2571: Object is of type 'unknown'.
2 const kobo = (await rate) * 100;
~~~~~~~~~~~~
Found 1 error in rate.ts:2Give T explicitly, or let an annotated return type provide it. Here is a legacy bank-lookup SDK wrapped the typed way:
type Callback<T> = (error: Error | null, result?: T) => void;
function legacyLookupBank(code: string, callback: Callback<string>): void {
const banks: Record<string, string> = { "058": "GTBank", "044": "Access Bank" };
setTimeout(() => {
const name = banks[code];
if (name === undefined) callback(new Error(`unknown bank code ${code}`));
else callback(null, name);
}, 5);
}
function lookupBank(code: string): Promise<string> {
return new Promise((resolve, reject) => {
legacyLookupBank(code, (error, name) => {
if (error !== null) reject(error);
else if (name === undefined) reject(new Error("bank lookup returned nothing"));
else resolve(name);
});
});
}
console.log(await lookupBank("058"));
try {
await lookupBank("999");
} catch (error) {
console.log(error instanceof Error ? error.message : error);
}
npx tsx bank-lookup.ts and of the browser terminalGTBank unknown bank code 999
The return type Promise<string> flows into new Promise by contextual typing, so resolve accepts only a string. That is why the callback checks name === undefined: the SDK's type says result?: T, and resolve(name) with a possibly-undefined name would not compile. reject, on the other hand, takes any (a rejection has no type, as you saw in Typed error handling). Node's util.promisify does this wrapping for callbacks in Node's error-first style.
Promise.withResolvers<T>() gives you the promise and its two functions separately, typed by the same T, which suits events that arrive later from elsewhere:
interface PaymentEvent {
reference: string;
status: "success" | "failed";
}
const waiting = new Map<string, (event: PaymentEvent) => void>();
function waitForWebhook(reference: string): Promise<PaymentEvent> {
const { promise, resolve } = Promise.withResolvers<PaymentEvent>();
waiting.set(reference, resolve);
return promise;
}
function onWebhook(event: PaymentEvent): void {
waiting.get(event.reference)?.(event);
waiting.delete(event.reference);
}
setTimeout(() => onWebhook({ reference: "TRF-77", status: "success" }), 10);
const event = await waitForWebhook("TRF-77");
console.log(event.reference, event.status);
npx tsx webhook-wait.ts and of the browser terminalTRF-77 success
Awaited: the type of await
await unwraps a promise, and a promise of a promise, and anything with a then method (a thenable, the PromiseLike<T> type). It leaves other values alone. The type that describes this is the built-in Awaited<T>:
import type { Equal, Expect } from "./type-tests.js";
interface Account {
id: string;
balanceKobo: number;
}
type A1 = Expect<Equal<Awaited<Promise<Account>>, Account>>;
type A2 = Expect<Equal<Awaited<Promise<Promise<number>>>, number>>;
type A3 = Expect<Equal<Awaited<PromiseLike<boolean>>, boolean>>;
type A4 = Expect<Equal<Awaited<string>, string>>;
type A5 = Expect<Equal<Awaited<Promise<number> | string>, number | string>>;
async function loadAccount(id: string): Promise<Account> {
return { id, balanceKobo: 125_000 };
}
type Loaded = Awaited<ReturnType<typeof loadAccount>>;
const account: Loaded = await loadAccount("ACC-9");
console.log(account.balanceKobo);
npx tsx awaited.ts and of the browser terminal125000
Awaited<ReturnType<typeof fn>> is the everyday use: the type an async function eventually produces, without writing it again. Utility types rebuilds Awaited yourself.
Why generic async helpers need Awaited
Awaited matters most in generic code. Here is a helper that runs a task and logs how it went. The task may return a plain value or a promise, so its type is () => T. The first attempt at its return type is Promise<T>, and the function body compiles. The trouble shows up at a call site:
async function withAudit<T>(label: string, task: () => T): Promise<T> {
const result = await task();
console.log(`${label}: done`);
return result;
}
withAudit("balance", async () => ({ balanceKobo: 900_000 })).then((balance) => {
console.log(balance.balanceKobo);
});
npx tsc --noEmit printswith-audit-wrong.ts:8:23 - error TS2339: Property 'balanceKobo' does not exist on type 'Promise<{ balanceKobo: number; }>'.
8 console.log(balance.balanceKobo);
~~~~~~~~~~~
with-audit-wrong.ts:8:23 - Did you forget to use 'await'?
8 console.log(balance.balanceKobo);
~~~~~~~~~~~
Found 1 error in with-audit-wrong.ts:8At runtime balance is the object: await task() unwrapped the promise. But the signature says otherwise. The task returns Promise<{ balanceKobo: number }>, so T is that promise, and Promise<T> claims the helper resolves to a promise, which can never happen. The signature lies, and a correct caller is rejected. (The compiler's hint about await is a red herring here: the fix belongs in the signature.) The honest return type is Promise<Awaited<T>>:
import type { Equal, Expect } from "./type-tests.js";
async function withAudit<T>(label: string, task: () => T): Promise<Awaited<T>> {
const result = await task();
console.log(`${label}: done`);
return result;
}
const fromSync = await withAudit("fee", () => 1500);
const fromAsync = await withAudit("balance", async () => ({ balanceKobo: 900_000 }));
type W1 = Expect<Equal<typeof fromSync, number>>;
type W2 = Expect<Equal<typeof fromAsync, { balanceKobo: number }>>;
console.log(fromSync, fromAsync.balanceKobo);
npx tsx with-audit.ts and of the browser terminalfee: done balance: done 1500 900000
Promise.all and tuple types
A dashboard needs an account, its recent transactions and today's exchange rate. They do not depend on each other, so you start all three at once with Promise.all. Its type is written so that an array literal of promises produces a tuple of results, each in its own position with its own type:
export interface Account {
id: string;
name: string;
balanceKobo: number;
}
export interface Transaction {
id: string;
kobo: number;
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export async function loadAccount(id: string): Promise<Account> {
await wait(20);
return { id, name: "Ada Stores", balanceKobo: 4_500_000 };
}
export async function loadTransactions(accountId: string): Promise<Transaction[]> {
await wait(10);
return [{ id: `${accountId}-T1`, kobo: 250_000 }, { id: `${accountId}-T2`, kobo: -80_000 }];
}
export async function loadRate(currency: "USD" | "GBP"): Promise<number> {
await wait(5);
return currency === "USD" ? 1540.25 : 1950.5;
}
import { loadAccount, loadRate, loadTransactions } from "./api.js";
import type { Account, Transaction } from "./api.js";
const [account, transactions, usdRate] = await Promise.all([
loadAccount("ACC-1"),
loadTransactions("ACC-1"),
loadRate("USD"),
]);
const typed: [Account, Transaction[], number] = [account, transactions, usdRate];
console.log(account.name, transactions.length, usdRate);
console.log(`balance in dollars: ${(account.balanceKobo / 100 / usdRate).toFixed(2)}`, typed.length);
npx tsx dashboard.ts and of the browser terminalAda Stores 2 1540.25 balance in dollars: 29.22 3
Each variable has its own type, so account.name and transactions.length both compile, and the line that assigns them to [Account, Transaction[], number] proves it. Under the hood, Promise.all is declared with a type parameter for the whole input tuple and a mapped type that applies Awaited to each position, which you will be able to read yourself after Mapped types.
When the tuple collapses
The tuple only exists when TypeScript sees an array literal. Build the array first, and it is inferred as an ordinary array whose element type is a union. The positions are lost:
import { loadAccount, loadRate } from "./api.js";
const jobs = [loadAccount("ACC-1"), loadRate("USD")];
const [account, rate] = await Promise.all(jobs);
console.log(account.name, rate.toFixed(2));
npx tsc --noEmit printscollapsed.ts:5:21 - error TS2339: Property 'name' does not exist on type 'number | Account'.
Property 'name' does not exist on type 'number'.
5 console.log(account.name, rate.toFixed(2));
~~~~
collapsed.ts:5:32 - error TS2339: Property 'toFixed' does not exist on type 'number | Account'.
Property 'toFixed' does not exist on type 'Account'.
5 console.log(account.name, rate.toFixed(2));
~~~~~~~
Found 2 errors in the same file, starting at: collapsed.ts:5jobs is (Promise<Account> | Promise<number>)[], so each result is Account | number, and neither name nor toFixed is safe. Add as const to keep the tuple (from Tuples):
import { loadAccount, loadRate } from "./api.js";
const jobs = [loadAccount("ACC-1"), loadRate("USD")] as const;
const [account, rate] = await Promise.all(jobs);
console.log(account.name, rate.toFixed(2));
npx tsx collapsed.ts and of the browser terminalAda Stores 1540.25
When the promises really are a list of the same kind, the array type is exactly right: Promise.all(ids.map(loadAccount)) gives Account[]. The tuple matters when the positions have different types.
race, any and a typed timeout
Promise.race and Promise.any settle with one of their inputs, so their result type is the union of the input types. That union is how a typed timeout works. A promise that can only reject never produces a value, so its type is Promise<never>, and T | never is just T:
export async function withTimeout<T>(task: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms} ms`)), ms);
});
try {
return await Promise.race([task, timeout]);
} finally {
clearTimeout(timer);
}
}
async function slowRate(): Promise<number> {
await new Promise((resolve) => setTimeout(resolve, 50));
return 1540.25;
}
console.log(await withTimeout(slowRate(), 200, "rate lookup"));
try {
await withTimeout(slowRate(), 10, "rate lookup");
} catch (error) {
console.log(error instanceof Error ? error.message : error);
}
npx tsx timeout.ts and of the browser terminal1540.25 rate lookup timed out after 10 ms
Three typed details make this helper safe to reuse. Promise.race([task, timeout]) is Promise<T | never>, which is Promise<T>, so callers get their own type back. ReturnType<typeof setTimeout> is the timer's type in whatever environment you compile for (a Timeout object in Node, a number in browsers), so you never write either. And the finally clears the timer; without it every successful call would leave a timer running, which keeps a Node process alive. The slow task that lost the race is not cancelled, only ignored; real cancellation uses an AbortSignal (Concurrency).
Promise.any has the same union result type. When every input rejects, it rejects with an AggregateError, whose errors property is typed any[]. Treat its entries as unknown.
Promise.allSettled and narrowing results
A dashboard should still load when the exchange-rate widget fails. Promise.allSettled never rejects; it gives a result object per promise, typed PromiseSettledResult<T>. That type is a discriminated union on status:
| Member | status | Other property |
|---|---|---|
PromiseFulfilledResult<T> | "fulfilled" | value: T |
PromiseRejectedResult | "rejected" | reason: any |
With an array literal you again get a tuple, so each position is narrowed on its own:
async function loadBalance(): Promise<number> {
return 4_500_000;
}
async function loadRate(): Promise<number> {
throw new Error("rates service returned 503");
}
async function loadAlerts(): Promise<string[]> {
return ["New login from Lagos"];
}
const [balance, rate, alerts] = await Promise.allSettled([loadBalance(), loadRate(), loadAlerts()]);
const lines: string[] = [];
lines.push(balance.status === "fulfilled" ? `balance ₦${balance.value / 100}` : "balance unavailable");
if (rate.status === "fulfilled") {
lines.push(`$1 = ₦${rate.value}`);
} else {
const reason: unknown = rate.reason;
lines.push(`rate unavailable (${reason instanceof Error ? reason.message : String(reason)})`);
}
if (alerts.status === "fulfilled") lines.push(...alerts.value.map((alert) => `alert: ${alert}`));
console.log(lines.join("\n"));
npx tsx settled.ts and of the browser terminalbalance ₦45000 rate unavailable (rates service returned 503) alert: New login from Lagos
After rate.status === "fulfilled", rate.value is a number; in the else, only reason exists. reason is any, so the example copies it into an unknown variable first, and the compiler then insists on narrowing it.
Splitting a list of settled results
For a list of the same kind of promise, split the results into successes and failures. Since TypeScript 5.5, an arrow function that just checks the discriminant is inferred as a type guard, so filter narrows the element type:
async function sendReceipt(email: string): Promise<string> {
if (!email.includes("@")) throw new Error(`invalid address: ${email}`);
return `sent to ${email}`;
}
const emails = ["ada@example.com", "grace.example.com", "tunde@example.com"];
const results = await Promise.allSettled(emails.map(sendReceipt));
const sent = results.filter((result) => result.status === "fulfilled").map((result) => result.value);
const failed = results.filter((result) => result.status === "rejected").map((result) => String(result.reason));
console.log(sent);
console.log(failed);
npx tsx settle-list.ts and of the browser terminal[ 'sent to ada@example.com', 'sent to tunde@example.com' ] [ 'Error: invalid address: grace.example.com' ]
sent is a string[] with no casts: the first filter narrowed each element to PromiseFulfilledResult<string>, so .value is allowed. The order of results matches the order of emails, whichever send finished first.
Async callbacks the compiler cannot check
Back to the opening bug. A callback type decides what may be returned from a callback. When the parameter type says the return value is void or unknown, TypeScript accepts a function that returns a promise, and the promise is silently ignored. The common victims:
| Call | Callback return type | What an async callback does |
|---|---|---|
array.filter(fn) | unknown | Keeps every item: a promise is truthy |
array.some(fn), every(fn), find(fn) | unknown | Answers from the truthiness of a promise |
array.forEach(fn) | void | Starts every call and returns before any finishes; rejections float |
setTimeout(fn), emitter.on(event, fn) | void | Nobody awaits the promise; rejections float |
Here is the forEach case, in a receipts job:
async function sendReceipt(orderId: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`receipt sent for ${orderId}`);
}
async function sendAllReceipts(orderIds: string[]): Promise<void> {
orderIds.forEach(async (orderId) => {
await sendReceipt(orderId);
});
console.log("all receipts sent");
}
await sendAllReceipts(["ORD-1", "ORD-2"]);
console.log("job finished");
npx tsx foreach.ts and of the browser terminalall receipts sent job finished receipt sent for ORD-1 receipt sent for ORD-2
The job reports success before anything was sent. forEach calls each callback, gets back a promise it does not look at, and returns. The fixes are to say which shape you want: one at a time with for...of, or all at once with Promise.all and map, whose callback return type is kept, so the result is a Promise<void>[] you can await:
async function sendReceipt(orderId: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`receipt sent for ${orderId}`);
}
async function sendAllReceipts(orderIds: string[]): Promise<void> {
await Promise.all(orderIds.map((orderId) => sendReceipt(orderId)));
console.log("all receipts sent");
}
async function isSuspicious(kobo: number): Promise<boolean> {
return kobo > 10_000_000;
}
async function filterAsync<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T[]> {
const keep = await Promise.all(items.map(predicate));
return items.filter((_, index) => keep[index]);
}
await sendAllReceipts(["ORD-1", "ORD-2"]);
console.log(await filterAsync([450_000, 25_000_000, 90_000], isSuspicious));
npx tsx foreach-fixed.ts and of the browser terminalreceipt sent for ORD-1 receipt sent for ORD-2 all receipts sent [ 25000000 ]
filterAsync fixes the payout job from the first section: it waits for all the answers, then filters synchronously on the real booleans. The type of predicate, (item: T) => Promise<boolean>, documents that it expects an async check. For the cases the compiler cannot see, the linter can: @typescript-eslint/no-misused-promises reports a promise-returning function passed where a void or boolean-ish callback is expected, and no-floating-promises reports promises nobody handles.
Async iterables and async generators
A payments API returns transactions in pages: twenty at a time, with a cursor (an opaque string) that asks for the next page. Code that wants "every transaction" should not have to know about pages. The iterator protocol from Iterators has an async version for this, and TypeScript types all of it:
| Type | Means |
|---|---|
AsyncIterable<T> | Has a [Symbol.asyncIterator]() method; usable in for await...of |
AsyncIterator<T> | Has next(), returning Promise<IteratorResult<T>> |
AsyncGenerator<T, TReturn, TNext> | What an async function* returns: yields T, finishes with TReturn, receives TNext from next(value). It is both of the above. |
First the page type and a fake API that counts its requests:
export interface Page<T> {
items: T[];
nextCursor: string | null;
}
export interface Transaction {
id: string;
kobo: number;
}
const PAGES: Record<string, Page<Transaction>> = {
start: { items: [{ id: "T1", kobo: 250_000 }, { id: "T2", kobo: -80_000 }], nextCursor: "c2" },
c2: { items: [{ id: "T3", kobo: 1_200_000 }], nextCursor: "c3" },
c3: { items: [{ id: "T4", kobo: -15_000 }, { id: "T5", kobo: 60_000 }], nextCursor: null },
};
export const stats = { requests: 0 };
export async function fetchTransactions(cursor: string | null): Promise<Page<Transaction>> {
stats.requests += 1;
await new Promise((resolve) => setTimeout(resolve, 5));
const page = PAGES[cursor ?? "start"];
if (page === undefined) throw new Error(`unknown cursor ${cursor}`);
return page;
}
Now a generic pager. It takes any function that fetches a page of T and yields the items one by one:
import type { Page } from "./pages.js";
export async function* paginate<T>(
fetchPage: (cursor: string | null) => Promise<Page<T>>,
maxPages = 100,
): AsyncGenerator<T, number, undefined> {
let cursor: string | null = null;
let pages = 0;
do {
if (pages === maxPages) throw new Error(`stopped after ${maxPages} pages: the cursor never ended`);
const page = await fetchPage(cursor);
pages += 1;
yield* page.items;
cursor = page.nextCursor;
} while (cursor !== null);
return pages;
}
AsyncGenerator<T, number, undefined>: it yields items of typeT, returns the number of pages it read when it finishes, and expects nothing fromnext().Tis inferred from the argument, so the same pager works for transactions, customers or refunds.maxPagesguards against an API that keeps returning a cursor forever, a real failure mode. Thelet cursor: string | nullannotation is needed becausenullalone would be inferred as the typenull.
import { fetchTransactions, stats } from "./pages.js";
import { paginate } from "./paginate.js";
let net = 0;
for await (const transaction of paginate(fetchTransactions)) {
net += transaction.kobo;
}
console.log(`net ₦${net / 100} from ${stats.requests} requests`);
stats.requests = 0;
for await (const transaction of paginate(fetchTransactions)) {
if (transaction.kobo > 1_000_000) {
console.log(`first large transaction: ${transaction.id}`);
break;
}
}
console.log(`stopped early after ${stats.requests} requests`);
npx tsx totals.ts and of the browser terminalnet ₦14150 from 3 requests first large transaction: T3 stopped early after 2 requests
In the loop, transaction is a Transaction, inferred all the way from fetchTransactions. The second loop shows the laziness that makes this worth it: it found what it needed on page two and break stopped the generator, so page three was never requested.
Driving the generator by hand
for await hides the IteratorResult. Calling next() yourself shows it: a union discriminated by done, where the yielded type and the return type sit on different sides:
import { fetchTransactions } from "./pages.js";
import { paginate } from "./paginate.js";
const pager = paginate(fetchTransactions);
let step = await pager.next();
const ids: string[] = [];
while (!step.done) {
ids.push(step.value.id);
step = await pager.next();
}
console.log(ids.join(","), "| pages read:", step.value);
npx tsx manual.ts and of the browser terminalT1,T2,T3,T4,T5 | pages read: 3
While done is false, step.value is a Transaction. After the loop, done is true, so step.value is the number the generator returned. With a return type of void, that last value would be undefined, which is the common case.
A class that is async iterable
Any object with a [Symbol.asyncIterator] method works with for await. Declaring implements AsyncIterable<T> makes the compiler check the method. Returning a generator from it is the shortest implementation:
import { fetchTransactions } from "./pages.js";
import type { Transaction } from "./pages.js";
import { paginate } from "./paginate.js";
class Statement implements AsyncIterable<Transaction> {
private readonly minimumKobo: number;
constructor(minimumKobo: number) {
this.minimumKobo = minimumKobo;
}
async *[Symbol.asyncIterator](): AsyncGenerator<Transaction, void, undefined> {
for await (const transaction of paginate(fetchTransactions)) {
if (Math.abs(transaction.kobo) >= this.minimumKobo) yield transaction;
}
}
}
async function collect<T>(source: AsyncIterable<T>): Promise<T[]> {
const items: T[] = [];
for await (const item of source) items.push(item);
return items;
}
const large = await collect(new Statement(100_000));
console.log(large.map((transaction) => `${transaction.id}:${transaction.kobo}`));
npx tsx statement.ts and of the browser terminal[ 'T1:250000', 'T3:1200000' ]
collect accepts any AsyncIterable<T>: the class, a generator, or a Node.js stream. Array.fromAsync does the same job, but its types live in the esnext library, so with "target": "ES2024" the compiler does not know it yet. A small typed helper like this is a fine alternative.
Build: a typed payments API client
REASON IT OUT
Before you write the client
You are writing the client your dashboard uses to talk to the payments API: fetch the merchant profile, the balance and the exchange rate, and stream all settlements. Before reading the code, think:
- What type does
await response.json()give you, and what can you trust about the data? - Which calls must succeed for the dashboard to make sense, and which are optional?
- What should happen when one call hangs?
- What happens if the settlements endpoint keeps sending a
nextCursor? - Where should the types come from, so that a change in one place is checked everywhere?
Show the reasoning
response.json() is typed Promise<any>, which is a hole in every type after it: the data comes from another program, and no type makes it correct. So every response goes through a parser that takes unknown and returns a checked type, the pattern from Runtime validation. The profile and balance are required (use Promise.all, fail fast); the rate is optional (settle it separately). Every request gets a timeout. The pager keeps its page limit. And the types flow from the parsers: the client's methods infer their result types from them, so nothing is written twice.
The API is simulated in-process with the standard Response class, which exists in Node.js and browsers, so the client code is exactly what you would write against the real fetch:
const routes: Record<string, { status: number; body: unknown; delayMs: number }> = {
"/merchant": { status: 200, body: { id: "M-17", name: "Ada Stores" }, delayMs: 10 },
"/balance": { status: 200, body: { availableKobo: 4_500_000, pendingKobo: 120_000 }, delayMs: 5 },
"/rates/USD": { status: 200, body: { rate: "not-a-number" }, delayMs: 5 },
"/settlements": { status: 200, body: { items: [{ id: "S1", kobo: 900_000 }], nextCursor: "2" }, delayMs: 5 },
"/settlements?cursor=2": { status: 200, body: { items: [{ id: "S2", kobo: 1_100_000 }], nextCursor: null }, delayMs: 5 },
};
export async function fakeFetch(path: string): Promise<Response> {
const route = routes[path];
await new Promise((resolve) => setTimeout(resolve, route?.delayMs ?? 5));
if (route === undefined) return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
return new Response(JSON.stringify(route.body), { status: route.status });
}
Notice the rates endpoint: it returns a string where a number is expected, the kind of surprise a real API delivers on a bad day. First the parsers, each from unknown to a checked type:
export type Parser<T> = (data: unknown) => T;
function record(data: unknown, what: string): Record<string, unknown> {
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new TypeError(`${what}: expected an object`);
return data as Record<string, unknown>;
}
function num(value: unknown, what: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${what}: expected a number`);
return value;
}
function str(value: unknown, what: string): string {
if (typeof value !== "string") throw new TypeError(`${what}: expected a string`);
return value;
}
export const parseMerchant = (data: unknown) => {
const r = record(data, "merchant");
return { id: str(r.id, "merchant.id"), name: str(r.name, "merchant.name") };
};
export const parseBalance = (data: unknown) => {
const r = record(data, "balance");
return { availableKobo: num(r.availableKobo, "balance.availableKobo"), pendingKobo: num(r.pendingKobo, "balance.pendingKobo") };
};
export const parseRate = (data: unknown) => num(record(data, "rate").rate, "rate.rate");
export const parseSettlementPage = (data: unknown) => {
const r = record(data, "page");
if (!Array.isArray(r.items)) throw new TypeError("page.items: expected an array");
const items = r.items.map((item: unknown, i) => {
const s = record(item, `page.items[${i}]`);
return { id: str(s.id, "settlement.id"), kobo: num(s.kobo, "settlement.kobo") };
});
const nextCursor = r.nextCursor === null ? null : str(r.nextCursor, "page.nextCursor");
return { items, nextCursor };
};
The one as is backed by the check on the line before it, as in TypeScript and JavaScript together. None of the parsers has a written return type; their types are inferred and flow into the client. A schema library such as Zod gives you the same thing with less code, as Runtime validation shows. Now the client:
import { fakeFetch } from "./fake-api.js";
import { parseBalance, parseMerchant, parseRate, parseSettlementPage } from "./parsers.js";
import type { Parser } from "./parsers.js";
async function withTimeout<T>(task: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms} ms`)), ms);
});
try {
return await Promise.race([task, timeout]);
} finally {
clearTimeout(timer);
}
}
export function createClient(fetchImpl: (path: string) => Promise<Response>, timeoutMs = 100) {
async function get<T>(path: string, parse: Parser<T>): Promise<T> {
const response = await withTimeout(fetchImpl(path), timeoutMs, `GET ${path}`);
if (!response.ok) throw new Error(`GET ${path} failed with ${response.status}`);
const data: unknown = await response.json();
return parse(data);
}
return {
merchant: () => get("/merchant", parseMerchant),
balance: () => get("/balance", parseBalance),
rate: (currency: "USD" | "GBP") => get(`/rates/${currency}`, parseRate),
async *settlements() {
let path: string | null = "/settlements";
while (path !== null) {
const page: ReturnType<typeof parseSettlementPage> = await get(path, parseSettlementPage);
yield* page.items;
path = page.nextCursor === null ? null : `/settlements?cursor=${page.nextCursor}`;
}
},
};
}
export type Client = ReturnType<typeof createClient>;
export const client: Client = createClient(fakeFetch);
The key line is const data: unknown = await response.json(). Annotating the any as unknown closes the hole: from here on the compiler refuses to use data until a parser has checked it. get<T> infers T from the parser, so client.balance() is Promise<{ availableKobo: number; pendingKobo: number }> without anyone writing that type. settlements is an async generator method, async *settlements(). Its page needs an annotation: path is computed from page, and page from a generic call that takes path, so without it TypeScript reports that page "is referenced directly or indirectly in its own initializer" (error TS7022). It also needs the page limit from the pager, which is left for you to add. Finally, the dashboard:
import { client } from "./client.js";
import type { Client } from "./client.js";
type Balance = Awaited<ReturnType<Client["balance"]>>;
function naira(kobo: number): string {
return `₦${(kobo / 100).toLocaleString("en-NG")}`;
}
async function loadDashboard(): Promise<string[]> {
const [[merchant, balance], [rate]] = await Promise.all([
Promise.all([client.merchant(), client.balance()]),
Promise.allSettled([client.rate("USD")]),
]);
const available: Balance["availableKobo"] = balance.availableKobo;
const lines = [`${merchant.name} (${merchant.id})`, `available ${naira(available)}, pending ${naira(balance.pendingKobo)}`];
if (rate.status === "fulfilled") {
lines.push(`available in USD: $${(available / 100 / rate.value).toFixed(2)}`);
} else {
lines.push(`USD rate unavailable: ${rate.reason instanceof Error ? rate.reason.message : "unknown error"}`);
}
let settled = 0;
for await (const settlement of client.settlements()) settled += settlement.kobo;
lines.push(`settled so far: ${naira(settled)}`);
return lines;
}
console.log((await loadDashboard()).join("\n"));
npx tsx dashboard.ts and of the browser terminalAda Stores (M-17) available ₦45,000, pending ₦1,200 USD rate unavailable: rate.rate: expected a number settled so far: ₦20,000
Read how each decision from the reasoning shows up:
- All three requests run in parallel. The outer
Promise.allholds two groups: an innerPromise.allfor the required data, which fails fast, and anallSettledfor the optional rate, which never rejects. The tuple types nest, somerchant,balanceandrateeach get their own type from one destructuring. - Starting the rate request on its own and awaiting it later would be the "start first, await later" trap from Promise combinators: if it rejected while the other two were still loading, nothing would be listening yet.
- The optional rate failed. The API's bad data was caught by
parseRate, and the dashboard shows a clear message instead of$NaN. Balanceis derived withAwaited<ReturnType<Client["balance"]>>, straight from the parser. Change the parser, and every use is re-checked.- The settlements loop never mentions a page or a cursor.
Testing async code
Async code needs two kinds of test. Type-level tests pin down what callers see; runtime tests pin down behaviour, including timing and laziness.
Type-level: the types callers get
import { client } from "./client.js";
import type { Client } from "./client.js";
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type Expect<T extends true> = T;
type C1 = Expect<Equal<Awaited<ReturnType<Client["rate"]>>, number>>;
type C2 = Expect<Equal<Awaited<ReturnType<Client["merchant"]>>, { id: string; name: string }>>;
type C3 = Expect<Equal<ReturnType<Client["settlements"]> extends AsyncIterable<infer S> ? S : never, { id: string; kobo: number }>>;
// @ts-expect-error: only USD and GBP are supported
const unsupported = () => client.rate("EUR");
console.log("type tests compiled", typeof unsupported);
npx tsx client.types.test.ts and of the browser terminaltype tests compiled function
C3 uses infer, which Conditional types explains, to pull the item type out of the generator. The @ts-expect-error line proves that an unsupported currency is refused. It is wrapped in a function that is never called, because a type test should not send requests: the line is still emitted and would run otherwise.
Runtime: laziness, limits and timeouts
import type { Page } from "./pages.js";
import { paginate } from "./paginate.js";
function fakeSource(pages: Page<number>[], endless = false) {
const calls: (string | null)[] = [];
const fetchPage = async (cursor: string | null): Promise<Page<number>> => {
calls.push(cursor);
const index = cursor === null ? 0 : Number(cursor);
const page = pages[index] ?? { items: [], nextCursor: null };
return endless ? { items: page.items, nextCursor: String(index) } : page;
};
return { calls, fetchPage };
}
async function check(label: string, run: () => Promise<unknown>, expected: string): Promise<void> {
let actual: string;
try {
actual = JSON.stringify(await run());
} catch (error) {
actual = `error: ${error instanceof Error ? error.message : String(error)}`;
}
console.log(`${actual === expected ? "PASS" : "FAIL"} ${label} -> ${actual}`);
}
const threePages: Page<number>[] = [
{ items: [1, 2], nextCursor: "1" },
{ items: [], nextCursor: "2" },
{ items: [3], nextCursor: null },
];
await check("reads every page, including an empty one", async () => {
const items: number[] = [];
for await (const item of paginate(fakeSource(threePages).fetchPage)) items.push(item);
return items;
}, "[1,2,3]");
await check("fetches lazily", async () => {
const source = fakeSource(threePages);
for await (const item of paginate(source.fetchPage)) if (item === 2) break;
return source.calls;
}, "[null]");
await check("stops an endless cursor", async () => {
for await (const item of paginate(fakeSource(threePages, true).fetchPage, 5)) void item;
return "finished";
}, "error: stopped after 5 pages: the cursor never ended");
npx tsx pager.test.ts and of the browser terminalPASS reads every page, including an empty one -> [1,2,3] PASS fetches lazily -> [null] PASS stops an endless cursor -> error: stopped after 5 pages: the cursor never ended
Each case comes from a question in the reasoning block: empty pages in the middle, stopping early, and an API that never ends. With Vitest, check becomes await expect(run()).resolves.toEqual(…) or .rejects.toThrow(…), and timeouts are tested with fake timers (vi.useFakeTimers()) so the test does not really wait. Testing TypeScript covers both.
Async TypeScript in production
- Annotate exported async functions.
Promise<Account>on a public function is documentation that cannot go stale, and it stops a changed body from quietly changing every caller. - Never let
Promise<any>in.response.json(),JSON.parseand many SDKs returnany. Annotate the result asunknownand parse it at the boundary. - Keep tuples for mixed
Promise.all. Pass the array literal directly, or useas const. - Time-limit every network call with a helper like
withTimeout, and clear the timer. Prefer passing anAbortSignalto APIs that accept one, such asfetch(url, { signal: AbortSignal.timeout(5000) }), so the work is really cancelled. - Turn on the promise lint rules from typescript-eslint:
no-floating-promises,no-misused-promises,await-thenableandreturn-await. They cover exactly the places where the compiler's types say "anything goes". - Bound everything that loops. Pagers get a page limit, retries get an attempt limit, and parallel work gets a concurrency limit (Concurrency).
Practice
TRY IT YOURSELF
A typed retry helper
Write retry<T>(task: () => Promise<T>, attempts: number): Promise<T> that calls task until it succeeds or attempts calls have failed, then rejects with the last error. Test it with a flaky rate lookup that fails twice.
Show a solution
async function retry<T>(task: () => Promise<T>, attempts: number): Promise<T> {
let lastError: unknown = new Error("retry needs at least one attempt");
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await task();
} catch (error) {
lastError = error;
console.log(`attempt ${attempt} failed`);
}
}
throw lastError;
}
let calls = 0;
async function flakyRate(): Promise<number> {
calls += 1;
if (calls < 3) throw new Error("rates service busy");
return 1540.25;
}
const rate = await retry(flakyRate, 5);
console.log(rate.toFixed(2), "after", calls, "calls");
try {
calls = 0;
await retry(flakyRate, 2);
} catch (error) {
console.log("gave up:", error instanceof Error ? error.message : error);
}
npx tsx retry.ts and of the browser terminalattempt 1 failed attempt 2 failed 1540.25 after 3 calls attempt 1 failed attempt 2 failed gave up: rates service busy
T is inferred from the task, so rate is a number. return await is required: without await, a rejection would skip the catch, the trap from Typed error handling. lastError is unknown, because that is all a catch can know. In production, add a delay between attempts and only retry errors that are retryable.
TRY IT YOURSELF
Rescue the collapsed tuple
This code does not compile. Explain why, and fix it without any as on the results.
async function countOrders(): Promise<number> {
return 42;
}
async function topProduct(): Promise<{ name: string; sold: number }> {
return { name: "Ankara tote bag", sold: 17 };
}
const work = [countOrders(), topProduct()];
const [orders, product] = await Promise.all(work);
console.log(`${orders} orders, best seller: ${product.name}`);
npx tsc --noEmit printssummary.ts:10:55 - error TS2339: Property 'name' does not exist on type 'number | { name: string; sold: number; }'.
Property 'name' does not exist on type 'number'.
10 console.log(`${orders} orders, best seller: ${product.name}`);
~~~~
Found 1 error in summary.ts:10Show a solution
async function countOrders(): Promise<number> {
return 42;
}
async function topProduct(): Promise<{ name: string; sold: number }> {
return { name: "Ankara tote bag", sold: 17 };
}
const [orders, product] = await Promise.all([countOrders(), topProduct()]);
console.log(`${orders} orders, best seller: ${product.name}`);
npx tsx summary.ts and of the browser terminal42 orders, best seller: Ankara tote bag
work was inferred as an array of a union of promises, so each result is number | { name: string; sold: number }, and product.name is not safe. Passing the array literal straight to Promise.all (or adding as const to work) keeps the tuple, and each position gets its own type.
TRY IT YOURSELF
Batches from an async iterable
Writing to the ledger is cheaper in batches. Write async function* batch<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[], void, undefined> that yields arrays of up to size items, and use it on an async generator of transaction ids.
Show a solution
async function* batch<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[], void, undefined> {
let current: T[] = [];
for await (const item of source) {
current.push(item);
if (current.length === size) {
yield current;
current = [];
}
}
if (current.length > 0) yield current;
}
async function* transactionIds(count: number): AsyncGenerator<string, void, undefined> {
for (let i = 1; i <= count; i++) {
await new Promise((resolve) => setTimeout(resolve, 1));
yield `TX-${i}`;
}
}
for await (const group of batch(transactionIds(7), 3)) {
console.log(`writing ${group.length}: ${group.join(" ")}`);
}
npx tsx batch.ts and of the browser terminalwriting 3: TX-1 TX-2 TX-3 writing 3: TX-4 TX-5 TX-6 writing 1: TX-7
The last, partial batch is yielded after the loop; forgetting it is the classic bug here. current = [] creates a new array instead of emptying the old one, because the consumer may still hold the batch it was given. And T flows from the source: group is a string[].
Recap
- An async function always returns
Promise<T>; its body returns plainTvalues, andawaitremoves one promise layer at the type level too. new Promise<T>needs itsTfrom a type argument or a return type, orresolvetakesunknown.Promise.withResolvers<T>()is typed the same way.Awaited<T>is the type ofawait: it unwraps nested promises and thenables. Generic helpers that await aTreturnPromise<Awaited<T>>.Promise.allon an array literal gives a typed tuple; a prebuilt array collapses into a union unless it isas const.raceandanygive unions, andPromise<never>makes typed timeouts.allSettledresults are a union onstatus;reasonisany, so treat it asunknown.- Async callbacks passed to
filter,some,forEachor event handlers are not checked; usePromise.allwithmap,for...of, and the promise lint rules. AsyncIterable<T>andAsyncGenerator<T, TReturn, TNext>type lazy async sequences; a generic pager hides pages and cursors from its callers.
This is the last lesson of the TypeScript course. Next, Type operators opens Advanced TypeScript: deriving types from values and from each other with keyof, typeof and indexed access.