TypeScript Survival Guide

Course manual

10 - Async: Promises, Await, and Typed Boundaries

Chapter 10 - Async: Promises, Await, and Typed Boundaries

Scouts gathering tinder and kindling beside an A-frame log stack at the fire ring

Day 4 opens at the fire ring, waiting. Charcoal must be heated to the red-hot stage before it ignites, and the top layer must turn gray before anyone spreads it to cook — spread too soon and each piece loses the neighbor-heat that lights it faster. There is no rushing coals; there is only holding a receipt for heat that finishes later and knowing exactly what that receipt promises. A Promise is that receipt in code: work that finishes later, with the fulfilled type written on it before the wait ever happens. This chapter types the waiting itself — receipts, unwrapping, boundaries, failure shapes — with a patrol roster first, then the production cuts that ship telemetry and pool game engines.

The receipt: Promise

A Promise starts pending, then settles exactly once: fulfilled with a value, or rejected with a reason. The T inside Promise<T> names the fulfilled type, and that name is the whole point:

function certifiedOf(patrol: Promise<Scout[]>): Promise<Scout[]> {
	return patrol.then((scouts) => scouts.filter((s) => s.certified));
}

patrol.then hands the callback a Scout[], not an unknown, because the receipt says so. The compiler checks the consumer before the network ever answers. A bare Promise with no type argument fulfills unknown, which forces every consumer to narrow first — the day-1 instinct, unchanged.

New here: Promise<T>, .then typing. Docs: Promises in the Handbook. Watch: 156 — Event Loop Visualized (“Whenever we work with promises, we’re working with the micro task queue” — promises queue continuations; the loop drains them in order).

Try It — Inline: Open VS Code, paste const p: Promise<Scout[]> = fetch("/patrol").then(r => r.json()) and hover p — the tooltip shows Promise<Scout[]>. Change to bare Promise and watch it widen to Promise<unknown>. No test file needed; the editor is the lab.

async wraps, await unwraps

Marking a function async wraps its return in a Promise. Calling await on a receipt pauses only that function until the receipt settles, then hands back the T — kept verbatim from the chapter slide:

async function getLead(patrol: Scout[]): Promise<Scout> {
	const found = patrol.find((s) => s.isLead);
	if (found === undefined) throw new Error("no lead listed");
	return found;
}
// src: assets/typescript/src/decks/async/slides/030-await-types.ts

Two details repay attention. First, returning a plain Scout from a function declared Promise<Scout> is correct: async does the wrapping. Second, find returns Scout | undefined, and the undefined arm throws — so the final return carries a proven Scout. Under the strict flags from chapter 12 that arm is not optional; the checker demands it.

New here: async, await. Docs: Async Functions. Watch: 156 — Event Loop Visualized again for the runtime half (“The event loop is a pretty notorious topic in JavaScript, but when we zoom out, it’s just a” — coordination mechanism: one thread, queued continuations).

Try It — Inline: In VS Code, write async function demo(p: Promise<string>) { return await p; } — hover the return, it is Promise<string> not string. Add : Promise<string> and watch the error, then fix by keeping async.

The network is a boundary

fetch returns a Promise<Response>, and response.json() parses to unknown. The network is a boundary — the line where outside data first enters your code — so its arrival earns a type with a guard, exactly like the kaper-chart guard from day 1. The isScout guard proves one record; fetchPage and loadPatrol prove the whole patrol — kept verbatim from the deck:

function isScout(value: unknown): value is Scout {
	return typeof value === "object" && value !== null
		&& "name" in value && "role" in value && "certified" in value;
}

interface PatrolResponse {
	readonly patrol: string;
	readonly scouts: readonly Scout[];
}

async function fetchPage(url: string): Promise<readonly Scout[]> {
	const res = await fetch(url);
	if (!res.ok) throw new Error(`patrol fetch failed: ${res.status}`);
	const data: unknown = await res.json();
	if (!Array.isArray(data) || !data.every(isScout)) throw new Error("stranger patrol page");
	return data;
}

async function loadPatrol(url: string): Promise<PatrolResponse> {
	const res = await fetch(url);
	if (!res.ok) throw new Error(`patrol fetch failed: ${res.status}`);
	return parsePatrolResponse(await res.json());
}
// src: assets/typescript/src/decks/async/slides/040-api-shapes.ts

Check res.ok first so HTTP failures become loud errors instead of mystery parses. Then isScout and parsePatrolResponse prove the unknown JSON and reject strangers — never resolve with a stranger; throw at the call site that cannot proceed without one. fetchPage uses isScout directly; loadPatrol delegates to parsePatrolResponse which internally calls isScout per record.

New here: fetch typing, Response.ok, unknown JSON, isScout guard. Docs: Type Assertions vs Guards. Watch: 155 — TypeScript with Express and Node (the typed workflow around the boundary: build, start, and dev scripts make the fetch-and-parse loop livable).

Try It — Inline: In VS Code, call isScout({ name: "Amara", role: "Lookout" }) — it returns false (missing certified). Add certified: true and it narrows to Scout with autocomplete on role.

Failures need shapes too

Three patterns cover async failure, each for a different situation. Throw when the caller cannot proceed: a failed fetch, a missing lead. These are dead ends with no sensible default, so a loud rejection beats a silent wrong answer. Narrow the caught value: under strict mode a catch clause delivers unknown, so a typeof or instanceof check precedes any report. Return an ok-or-error union when failure is routine — a bad query, a missing row — because the caller then handles both arms without try:

type LoadResult =
	| { readonly ok: true; readonly report: TrailReport }
	| { readonly ok: false; readonly error: string };

Unions force handling; exceptions allow skipping. Reserve silent swallowing for best-effort sends, and there is exactly one of those in this chapter’s production cut. Kyle’s complaint is the reason the union exists: “Promises in async await in JavaScript are amazing, but they really suck at handling errors” — wrapping every consumer line in try routes a typo like usr into the same catch that handles the 404, so his catchError returns the error as data in a [error, data] tuple instead of throwing it.

New here: unknown catches, ok-or-error unions, error tuples. Docs: Utility Types — Awaited. Watch: Video 109 — Ditching Try/Catch for Result Tuples (“Promises in async await in JavaScript are amazing, but they really suck at handling errors” — the generic catchError helper returns [error, data] tuples typed Promise<[undefined, T] | [Error]>, so errors travel as data).

Production cut: the telemetry fallback

The version badge in the demo reports a click to analytics. The preferred channel is navigator.sendBeacon, which survives page unload — but some environments lack it, so postAnalytics falls back to fetch, kept verbatim from the chapter slide:

fetch(ANALYTICS_ENDPOINT, {
	method: "POST",
	keepalive: true,
	headers: { "Content-Type": "application/json" },
	body: JSON.stringify(payload),
}).catch(() => {});
// src: assets/typescript/src/decks/async/slides/050-telemetry-cut.ts

keepalive: true keeps the request alive past unload, the content type declares the body, and the trailing catch swallows analytics errors so telemetry never breaks the game. Fire-and-forget returns nothing — the caller needs no receipt for a best-effort send. Degrade, never disrupt: that sentence is the error-pattern moral in production form.

Production cut: the pool that cannot hold a stranger

The host pool keeps resident 3D engines typed by their ids with a single generic declaration, kept verbatim from the chapter slide:

const pool = new Map<HostId, PooledHost>();
// src: assets/typescript/src/decks/async/slides/080-pool-generics.ts

The generic arguments do the work: HostId keys mean no lookup can name a host that does not exist, and PooledHost values keep each resident’s engine, scene, and usage stamp typed. Registration stamps a rising sequence number so eviction always identifies the most recent resident, and the cap holds at one — registering a newcomer disposes the old. Generics earning their keep inside async infrastructure.

Failing fast versus reporting everything

Promise.all takes an array of receipts and fulfills with the values in matching order while the requests fly together — kept verbatim from the chapter slide:

const [fire, river] = await Promise.all([
	loadPatrol(patrolUrl),
	loadPatrol(scheduleUrl),
]);

const outcomes = await Promise.allSettled([
	loadPatrol(patrolUrl),
	loadPatrol(scheduleUrl),
]);
// src: assets/typescript/src/decks/async/slides/070-settled.ts

One rejection rejects the whole all — the right choice when every input must succeed. allSettled waits for every receipt and reports each outcome separately, so one bad feed cannot hide the good ones. Destructure the all result to name each value at once; filter the settled array by status.

JavaScript deserves its due here: await yields the single thread instead of blocking it, so other work proceeds while the receipt is pending. The event loop drains queued continuations in order — timers, settled promises, render — which video 156 visualizes directly (“However, the event loop prioritizes the micro task queue”). Nothing about this is magic; it is scheduling, and the types describe the schedule.

New here: Promise.all, Promise.allSettled. Docs: Generics (all keeps each receipt’s seat through generic tuple inference — position in, position out). Watch: 156 — Event Loop Visualized once more for the ordering proof (“Some of these web APIs allow us to offload long running tasks to the browser” — the receipt leaves the thread; the queue brings the value home).

Try It — Inline: In VS Code, type await Promise.all([fetchPage("/a"), fetchPage("/b")]) — hover shows [Scout[], Scout[]] tuple preserved. Switch to allSettled and hover the status discriminant.

DIY simulator drills

You are the simulator. Cover the answers, predict, then verify. Log each miss in one sentence. One drill per file — Chapter 03 showed why.

Drill 1 — Be the receipt reader

Which function fails, and with which code?

async function shout(receipt: Promise<string>): Promise<string> {
	return (await receipt).toUpperCase();
}

async function count(receipt: Promise<unknown>): Promise<number> {
	return await receipt;
}

Verify with npx tsc --noEmit --strict --target es2020 (async needs a Promise-capable target — without it every line drowns in TS2705 and the real error hides). The named receipt unwraps to string; the bare one unwraps to unknown, which no number slot accepts (TS2322). Say which line before running.

Drill 2 — Be the catcher

Which return errors, and with which code?

async function leadName(load: () => Promise<string>): Promise<string> {
	try {
		return await load();
	} catch (e) {
		return e.message;
	}
}

Verify with npx tsc --noEmit --strict --target es2020. Under strict mode the caught value is unknown, so reading .message off it errors (TS18046) — without the flag this drill compiles clean and the lesson never appears. Fix it with an instanceof Error narrow and watch the error leave; that narrow is the lesson.

Drill 3 — Be the scheduler

What prints, in what order?

console.log("1");
Promise.resolve().then(() => console.log("2"));
setTimeout(() => console.log("3"), 0);
console.log("4");

Verify with node. (Synchronous lines run first, then the microtask queue drains before the timer — 1 4 2 3. The promise was already settled and still waited its turn; settled never means synchronous. Say the order before running.)

Drill 4 — Be the settler

Which annotation holds, and which fails with which code?

async function pair(a: Promise<string>, b: Promise<number>): Promise<[string, number]> {
	return await Promise.all([a, b]);
}

async function same(a: Promise<string>, b: Promise<number>): Promise<[string, string]> {
	return await Promise.all([a, b]);
}

Verify with npx tsc --noEmit --strict --target es2020. (all preserves each receipt’s position in the tuple, so the honest annotation passes and the wishful one fails with TS2322 at the tuple’s second seat. Order in, order out — that is the whole contract.)

Lab: a typed data layer (VS Code)

Open static/labs/async-data-layer/ in VS Code and run setup.sh. The fetcher is injected — each test hands loadReport a canned async function, so no real network is involved. You get stubs that throw and a red suite of five tests: typed fulfillment through the fetcher, loud HTTP failure (ok false with a 500 must reject, never resolve with a stranger), refused stranger JSON via isScout/parsePatrolResponse, order-preserving parallel loads through Promise.all, and a narrowed result the caller filters to a typed Scout[] with no cast. Implement parseTrailReport first (use isScout per record), loadReport second (check ok, then guard), go green with npm test && npm run typecheck inside VS Code’s terminal — then accept a stranger payload once and watch exactly one test fail. The red you cause on purpose is the proof the guard works. Keep the VS Code Problems panel open: isScout’s value is Scout return lights green only when the guard is exhaustive.