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

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 hoverp— the tooltip showsPromise<Scout[]>. Change to barePromiseand watch it widen toPromise<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 isPromise<string>notstring. Add: Promise<string>and watch the error, then fix by keepingasync.
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 returnsfalse(missingcertified). Addcertified: trueand it narrows toScoutwith autocomplete onrole.
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 toallSettledand hover thestatusdiscriminant.
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.