TypeScript · lesson 15 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Async TypeScript
Promises, a typed fetch wrapper, unknown errors, Promise.all/allSettled/race, AbortSignal, async generators and a concurrency queue — with real output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext --types node file.ts type-checks it (--types node loads Node’s globals; TypeScript 6 no longer includes @types packages automatically).
The fetch examples talk to this small local server. Start it in another terminal with npx tsx server.ts and leave it running; the curl calls below check it’s up:
import { createServer } from "node:http";
const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Carol" }];
createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const delay = Number(url.searchParams.get("delay") ?? 0);
await new Promise((r) => setTimeout(r, delay));
const match = url.pathname.match(/^\/users\/(\d+)$/);
const send = (status: number, body: unknown) => {
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
};
if (url.pathname === "/users") {
const page = Number(url.searchParams.get("page") ?? 1);
return send(200, { data: users.slice((page - 1) * 2, page * 2), hasMore: page * 2 < users.length });
}
const user = match && users.find((u) => u.id === Number(match[1]));
if (user) return send(200, user);
if (url.pathname === "/legacy/1") return send(200, { id: "1", fullName: "Alice" });
send(404, { error: "not found" });
}).listen(4400, () => console.log("listening on http://localhost:4400"));
$ curl -s localhost:4400/users/1
{"id":1,"name":"Alice"}
$ curl -s localhost:4400/users?page=2
{"data":[{"id":3,"name":"Carol"}],"hasMore":false}
$ curl -s -i localhost:4400/users/9 | head -1
HTTP/1.1 404 Not Found
?delay=ms on any request makes the server wait before answering, which the concurrency examples use.
Typing Promises
An async function always returns a Promise<T>. Forgetting await is a type error in most places — and TypeScript has a dedicated check for the most dangerous one.
async function isAdmin(userId: number): Promise<boolean> {
return userId === 1;
}
async function main() {
const flag: boolean = isAdmin(1);
if (isAdmin(2)) {
console.log("user 2 is an admin?");
}
}
main();
$ npx tsc --noEmit --module nodenext --types node promises.ts
promises.ts(6,9): error TS2322: Type 'Promise<boolean>' is not assignable to type 'boolean'.
promises.ts(7,7): error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined.
A Promise object is always truthy, so if (isAdmin(2)) would take the branch for every user — a real authorisation bug. Adding await in both places fixes it.
A Typed fetch Wrapper
response.json() returns Promise<any>, so every fetch is an any leak unless you wrap it once.
interface User { id: number; name: string }
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(`http://localhost:4400${url}`, init);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return (await res.json()) as T;
}
const user = await fetchJson<User>("/users/1");
console.log(user.name.toUpperCase());
const legacy = await fetchJson<User>("/legacy/1");
console.log(legacy.id + 1, legacy.name);
await fetchJson<User>("/users/9");
$ npx tsc --noEmit --module nodenext --types node fetch-json.ts
$ npx tsx fetch-json.ts 2>&1 | head -8
ALICE
11 undefined
/root/ts/async/fetch-json.ts:5
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
^
Error: HTTP 404 for /users/9
at fetchJson (/root/ts/async/fetch-json.ts:5:22)
It type-checks, and the first call is correct. The second shows the cost of as T: the legacy endpoint returns { id: "1", fullName: ... }, but the program believes it’s a User, so legacy.id + 1 does string concatenation and prints 11, and name is undefined. The assertion is the one place you’re trusting the server. The third call hits a 404, so the wrapper throws — and since nothing catches it, the process exits.
Checking the fields where data enters makes the trust explicit:
interface User { id: number; name: string }
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null
&& typeof (value as Record<string, unknown>).id === "number"
&& typeof (value as Record<string, unknown>).name === "string";
}
async function getUser(path: string): Promise<User> {
const body: unknown = await (await fetch(`http://localhost:4400${path}`)).json();
if (!isUser(body)) throw new Error(`unexpected shape from ${path}: ${JSON.stringify(body)}`);
return body;
}
for (const path of ["/users/2", "/legacy/1"]) {
try {
console.log(await getUser(path));
} catch (e) {
console.log((e as Error).message);
}
}
$ npx tsc --noEmit --module nodenext --types node validate.ts
$ npx tsx validate.ts
{ id: 2, name: 'Bob' }
unexpected shape from /legacy/1: {"id":"1","fullName":"Alice"}
value is User is a type predicate: when isUser returns true, the compiler treats body as a User, with no assertion. A schema library such as Zod generates the same kind of check from one declaration.
Errors Are unknown
Under strict, a catch variable is unknown, because JavaScript can throw anything — not just Error objects.
async function risky(kind: string): Promise<string> {
if (kind === "error") throw new Error("boom");
if (kind === "string") throw "plain string";
return "fine";
}
for (const kind of ["ok", "error", "string"]) {
try {
console.log(await risky(kind));
} catch (err) {
console.log(err.message);
}
}
$ npx tsc --noEmit --module nodenext --types node catch.ts
catch.ts(11,17): error TS18046: 'err' is of type 'unknown'.
$ npx tsx catch.ts
fine
boom
undefined
The runtime shows why the compiler complains: the thrown string has no message, so the last line prints undefined. A Result type moves the check into one helper:
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
async function attempt<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
return { ok: true, value: await fn() };
} catch (err) {
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
}
}
async function risky(kind: string): Promise<string> {
if (kind === "error") throw new Error("boom");
if (kind === "string") throw "plain string";
return "fine";
}
for (const kind of ["ok", "error", "string"]) {
const r = await attempt(() => risky(kind));
console.log(r.ok ? `value: ${r.value}` : `error: ${r.error.message}`);
}
$ npx tsc --noEmit --module nodenext --types node result.ts
$ npx tsx result.ts
value: fine
error: boom
error: plain string
Callers can’t read r.value without checking r.ok first — the discriminated union enforces it.
Promise Combinators
Independent requests should overlap. Promise.all also keeps each element’s type, giving a typed tuple.
interface User { id: number; name: string }
const get = <T>(path: string) => fetch(`http://localhost:4400${path}`).then((r) => r.json() as Promise<T>);
const since = (t: number) => `${Math.round((performance.now() - t) / 100) * 100}ms`;
let t = performance.now();
const a = await get<User>("/users/1?delay=300");
const b = await get<User>("/users/2?delay=300");
console.log("sequential", a.name, b.name, since(t));
t = performance.now();
const [c, page] = await Promise.all([
get<User>("/users/3?delay=300"),
get<{ data: User[]; hasMore: boolean }>("/users?page=1&delay=300"),
]);
console.log("parallel", c.name, page.data.length, since(t));
const settled = await Promise.allSettled([
get<User>("/users/1"),
fetch("http://localhost:9").then((r) => r.json()),
]);
settled.forEach((s) => console.log(s.status, s.status === "fulfilled" ? s.value.name : (s.reason as Error).message));
$ npx tsc --noEmit --module nodenext --types node combinators.ts
$ npx tsx combinators.ts
sequential Alice Bob 700ms
parallel Carol 2 300ms
fulfilled Alice
rejected fetch failed
(Times are rounded to the nearest 100 ms, and include connection setup.) Two awaited requests in a row took more than twice as long as the same two started together. In the Promise.all result, c is a User and page is the page object — a tuple, not an array of a union. Promise.allSettled never rejects; each entry is a discriminated union on status, so s.value is only reachable after checking for "fulfilled". Port 9 has nothing listening, so that request fails.
Timeouts and Cancellation
Promise.race settles with whichever promise settles first. An AbortSignal goes further and actually cancels the request.
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms),
);
return Promise.race([promise, timeout]);
}
const slow = fetch("http://localhost:4400/users/1?delay=500");
try {
await withTimeout(slow, 100);
} catch (e) {
console.log("race:", (e as Error).message);
}
try {
await fetch("http://localhost:4400/users/1?delay=500", { signal: AbortSignal.timeout(100) });
} catch (e) {
console.log("abort:", (e as Error).name, (e as Error).message);
}
$ npx tsc --noEmit --module nodenext --types node timeout.ts
$ npx tsx timeout.ts
race: timed out after 100ms
abort: TimeoutError The operation was aborted due to timeout
The timeout promise is typed Promise<never> because it can only reject, so Promise.race([promise, timeout]) is still a Promise<T>. But race only stops waiting: the slow request carries on in the background. AbortSignal.timeout cancels the request itself, and fetch rejects with a TimeoutError.
Async Generators for Pagination
An async function* hides page-by-page fetching behind a for await loop.
interface User { id: number; name: string }
interface Page<T> { data: T[]; hasMore: boolean }
async function* paginate<T>(fetchPage: (page: number) => Promise<Page<T>>): AsyncGenerator<T> {
for (let page = 1; ; page++) {
const { data, hasMore } = await fetchPage(page);
console.log(` (fetched page ${page})`);
yield* data;
if (!hasMore) return;
}
}
const users = paginate((page) =>
fetch(`http://localhost:4400/users?page=${page}`).then((r) => r.json() as Promise<Page<User>>),
);
for await (const user of users) {
console.log(user.id, user.name);
if (user.name === "Bob") break;
}
$ npx tsc --noEmit --module nodenext --types node paginate.ts
$ npx tsx paginate.ts
(fetched page 1)
1 Alice
2 Bob
Pages are fetched lazily, only when the loop asks for more — breaking after Bob means page 2 is never requested. T flows from the page fetcher into the loop variable, so user.name is typed without annotations in the loop.
A Worked Example: A Concurrency-Limited Queue
To fire many requests without overwhelming a server, cap how many run at once.
class AsyncQueue {
#queue: (() => void)[] = [];
#running = 0;
peak = 0;
constructor(private readonly limit: number) {}
async add<T>(task: () => Promise<T>): Promise<T> {
if (this.#running >= this.limit) await new Promise<void>((r) => this.#queue.push(r));
this.#running++;
this.peak = Math.max(this.peak, this.#running);
try {
return await task();
} finally {
this.#running--;
this.#queue.shift()?.();
}
}
}
interface User { id: number; name: string }
const queue = new AsyncQueue(2);
const start = performance.now();
const users = await Promise.all(
[1, 2, 3, 1, 2, 3].map((id) =>
queue.add(() => fetch(`http://localhost:4400/users/${id}?delay=200`).then((r) => r.json() as Promise<User>)),
),
);
console.log(users.map((u) => u.name).join(", "));
console.log(`peak ${queue.peak}, ${Math.round((performance.now() - start) / 100) * 100}ms`);
$ npx tsc --noEmit --module nodenext --types node queue.ts
$ npx tsx queue.ts
Alice, Bob, Carol, Alice, Bob, Carol
peak 2, 700ms
Six 200 ms requests with at most two in flight take three rounds — 600 ms of server delay plus request overhead — and the results still come back in submission order because Promise.all preserves positions. add is generic per call, so each task’s result type flows through — users is a User[]. The finally block releases a slot even when a task throws, which is the part hand-rolled queues most often get wrong.
Practice
1. Make withTimeout cancel the underlying request by taking a function that receives an AbortSignal.
Solution
async function withTimeout<T>(run: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
return run(AbortSignal.timeout(ms));
}
const url = "http://localhost:4400/users/2?delay=300";
for (const ms of [100, 1000]) {
try {
const user = await withTimeout((signal) => fetch(url, { signal }).then((r) => r.json()), ms);
console.log(ms, "->", user);
} catch (e) {
console.log(ms, "->", (e as Error).name);
}
}
$ npx tsc --noEmit --module nodenext --types node practice-1.ts
$ npx tsx practice-1.ts
100 -> TimeoutError
1000 -> { id: 2, name: 'Bob' }
Passing the signal into the operation is what lets it be cancelled; a promise that has already been created can only be ignored, not stopped.
2. Change paginate’s consumer to collect every user into an array with Array.fromAsync, and check the type.
Solution
interface User { id: number; name: string }
interface Page<T> { data: T[]; hasMore: boolean }
async function* paginate<T>(fetchPage: (page: number) => Promise<Page<T>>): AsyncGenerator<T> {
for (let page = 1; ; page++) {
const { data, hasMore } = await fetchPage(page);
yield* data;
if (!hasMore) return;
}
}
const all = await Array.fromAsync(
paginate((page) => fetch(`http://localhost:4400/users?page=${page}`).then((r) => r.json() as Promise<Page<User>>)),
);
const names: string[] = all.map((u) => u.name);
console.log(names);
$ npx tsc --noEmit --module nodenext --types node practice-2.ts
practice-2.ts(12,25): error TS2550: Property 'fromAsync' does not exist on type 'ArrayConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.
practice-2.ts(15,34): error TS7006: Parameter 'u' implicitly has an 'any' type.
$ npx tsc --noEmit --module nodenext --types node --target esnext practice-2.ts
$ npx tsx practice-2.ts
[ 'Alice', 'Bob', 'Carol' ]
Array.fromAsync drains an async iterable into a Promise<User[]>. Node 22 has it, but it’s newer than TypeScript 6’s default target, so the compiler doesn’t know it exists until you raise target (or lib) to esnext — the second error is just a knock-on from the first. tsx doesn’t check types, so it ran either way.
3. Run the queue with a limit of 3. What peak and total time do you expect?
Solution
class AsyncQueue {
#queue: (() => void)[] = [];
#running = 0;
peak = 0;
constructor(private readonly limit: number) {}
async add<T>(task: () => Promise<T>): Promise<T> {
if (this.#running >= this.limit) await new Promise<void>((r) => this.#queue.push(r));
this.#running++;
this.peak = Math.max(this.peak, this.#running);
try {
return await task();
} finally {
this.#running--;
this.#queue.shift()?.();
}
}
}
const queue = new AsyncQueue(3);
const start = performance.now();
await Promise.all([1, 2, 3, 1, 2, 3].map((id) =>
queue.add(() => fetch(`http://localhost:4400/users/${id}?delay=200`).then((r) => r.json())),
));
console.log(`peak ${queue.peak}, ${Math.round((performance.now() - start) / 100) * 100}ms`);
$ npx tsx practice-3.ts
peak 3, 500ms
Peak 3, and two rounds of 200 ms instead of three, so roughly 200 ms less than with a limit of 2.