Hardening it with advanced types and strictness
The happy path works, and every failure already returns a correct Result. But "returns a correct Result" is not the same as "handled well". This lesson hardens the client: rendering every outcome for a caller, using the compiler to guarantee no failure is ever silently ignored, and adding the two real-world concerns a live API demands — a timeout and input validation. This is where typed error handling stops being a pattern you follow and becomes a safety net the compiler holds for you.
Consuming a Result: rendering every outcome
A caller has a Result<Weather> and must turn it into something — a message, a UI state, a log line.
Because both the success and every failure are typed, the caller can handle each precisely:
function describe(result: Result<Weather>): string {
if (result.ok) {
const w = result.value;
return `${w.temperatureC}°C, wind ${w.windSpeedKmh} km/h, ${w.condition}`;
}
const e = result.error;
switch (e.kind) {
case "network": return "Could not reach the weather service.";
case "http": return `Weather service error (HTTP ${e.status}).`;
case "badShape": return `Unexpected response: ${e.detail}.`;
}
}
Two things the types are doing for you here. First, inside if (result.ok) the compiler knows value
exists (and error does not); in the else path it is the reverse — the discriminant narrows both arms.
Second, inside case "http" you can read e.status, and inside case "badShape" you can read e.detail,
but you could not read status in the network case — each variant exposes exactly its own data. This is
the unions-and-narrowing module paying off in real code: no optional-chaining guesswork, no "does this
error have a status?" — the type of each branch is exact.
The exhaustiveness guarantee: making a forgotten case a compile error
Here is the single most valuable safety property in the whole client. The describe function above works
today — but what happens when we add a fourth failure kind tomorrow and forget to handle it? By default,
nothing — it silently falls through. We make that impossible with the never trick from the
exhaustiveness lesson:
function describe(result: Result<Weather>): string {
if (result.ok) {
const w = result.value;
return `${w.temperatureC}°C, wind ${w.windSpeedKmh} km/h, ${w.condition}`;
}
const e = result.error;
switch (e.kind) {
case "network": return "Could not reach the weather service.";
case "http": return `Weather service error (HTTP ${e.status}).`;
case "badShape": return `Unexpected response: ${e.detail}.`;
default: {
const _exhaustive: never = e; // every case handled ⇒ e is `never` here
return _exhaustive;
}
}
}
If every kind is handled, control never reaches default, so e narrows to never, and const _exhaustive: never = e compiles. But the moment we add { kind: "timeout" } to WeatherError and do
not add a case "timeout", e in the default is { kind: "timeout" }, which is not assignable to
never — and the build fails with "Type '{ kind: "timeout"; }' is not assignable to type 'never'"
(TS2322). The compiler now forces us to handle every new failure the moment we introduce it. A whole
class of "we added an error and forgot to handle it somewhere" bug is eliminated — not by discipline, but
by the type system. This is the capstone's headline lesson: model failures as a union, handle them with
narrowing, and let never guard the exhaustiveness.
Adding a real timeout
A live API call that hangs forever is a real failure mode our three kinds do not yet cover. We add a
timeout kind and enforce it with AbortController — and the exhaustiveness check we just added will
insist we handle it, which is exactly what we want:
type WeatherError =
| { kind: "network" }
| { kind: "http"; status: number }
| { kind: "badShape"; detail: string }
| { kind: "timeout"; ms: number }; // NEW — the request took too long
The moment you save this, describe stops compiling (timeout is unhandled) — the type system has caught
the omission before you ran anything. Add the case and the request logic:
case "timeout": return `Weather service timed out after ${e.ms} ms.`;
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer); // always clear — success or failure
}
}
In getWeather, an abort surfaces as a rejected promise, so it lands in the same catch as a network
failure; you distinguish them by inspecting the error (err instanceof Error && err.name === "AbortError")
and return { kind: "timeout", ms } rather than { kind: "network" }. The finally clearing the timer is
the resource-cleanup habit — a timer you set must always be cleared, on every path.
Validating input at the boundary too
The typing-apis lesson was about untrusted data coming in from the API — but a caller passing nonsense coordinates is the same problem from the other side. Latitude must be -90..90 and longitude -180..180; anything else is a bug we should reject clearly rather than send to the API and get a confusing answer:
type WeatherError =
/* ...existing... */
| { kind: "badInput"; detail: string };
function validateCoords(lat: number, lon: number): WeatherError | null {
if (Number.isNaN(lat) || lat < -90 || lat > 90) return { kind: "badInput", detail: `latitude ${lat}` };
if (Number.isNaN(lon) || lon < -180 || lon > 180) return { kind: "badInput", detail: `longitude ${lon}` };
return null; // valid
}
getWeather calls this first and returns the badInput Result if it fails. Again, adding the badInput
kind makes describe fail to compile until you handle it — the exhaustiveness check turns "I added a
failure mode" into "the compiler listed every place I must update". You could go further and make invalid
coordinates unrepresentable with branded types (Latitude/Longitude from the best-practices module),
so a caller cannot even call getWeather without validating first; the practice explores that. For most
code, validating at the entry and returning a typed error is the right amount of rigour.
What hardening bought us
Step back and see what the type system is now enforcing, none of which cost runtime code:
- No unchecked result access —
valueis unreadable untilokis confirmed. - No ignored failure — every failure is in the return type, and
describemust address each. - No forgotten new failure — the
neverguard turns an unhandledkindinto a build error. - No leaked resources — the timeout's timer is always cleared.
- No garbage input reaching the API — coordinates are validated at the boundary.
A plain-JavaScript version of this client could implement the same behaviour, but nothing would stop a future edit from breaking any of these. The typed version makes the guarantees structural: to break one, you would have to fight a compile error. That difference — behaviour you hope holds versus behaviour the compiler holds for you — is the entire reason the course exists.
Check your work
How narrowing serves the consumer. if (result.ok) exposes value; the switch (error.kind) exposes
exactly each variant's data (status for http, detail for badShape) and nothing else.
What the never trick guarantees. When every case is handled, error narrows to never in default
and const _exhaustive: never = error compiles; an unhandled new kind is not assignable to never —
a compile error (TS2322) that forces you to handle it.
Why add timeout as a union member. A hanging request is a real failure; making it a kind means the
exhaustiveness check forces every consumer to handle it.
Why finally { clearTimeout(timer) }. A timer set must always be cleared, on success and failure alike
— resource cleanup on every path.
Why validate coordinates. Untrusted input comes from callers too; reject out-of-range coordinates with
a typed badInput error rather than sending garbage to the API.
The five structural guarantees. No unchecked result access, no ignored failure, no forgotten new failure, no leaked timer, no garbage input — all enforced by types, not discipline.
Practice
- Write the exhaustive
describewith theneverdefault. Add{ kind: "timeout"; ms: number }toWeatherErrorand confirmdescribefails to compile before you add the case (read the TS2322). - Implement
fetchWithTimeoutand trigger a timeout (point at a slow/unroutable URL with a shortms); confirm you get atimeoutResult, notnetwork. - Distinguish abort from other network errors in the
catchby checkingerr.name === "AbortError". - Add
badInputandvalidateCoords; callgetWeather(200, 0, fetch)and confirm abadInputResult before any network call happens. - Remove the
defaultblock, delete onecase, and confirm the function still compiles — demonstrating why theneverguard is what makes exhaustiveness enforced rather than merely present. - (Stretch) Introduce branded
Latitude/Longitudetypes with amakeLatitudesmart constructor, and changegetWeatherto require them — making invalid coordinates unrepresentable at the call site.
Official documentation
- TypeScript — Exhaustiveness checking — The
neverguard, in the handbook. - MDN — AbortController — Cancelling a fetch for the timeout.
- MDN — Number.isNaN — Correctly rejecting
NaNinput.
Next: testing the client — fakes and type-level tests.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship