RizTech Academy logo
RizTech Academy
Capstone: A Typed Library and ClientLesson 4 of 540 min

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 — value is unreadable until ok is confirmed.
  • No ignored failure — every failure is in the return type, and describe must address each.
  • No forgotten new failure — the never guard turns an unhandled kind into 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

  1. Write the exhaustive describe with the never default. Add { kind: "timeout"; ms: number } to WeatherError and confirm describe fails to compile before you add the case (read the TS2322).
  2. Implement fetchWithTimeout and trigger a timeout (point at a slow/unroutable URL with a short ms); confirm you get a timeout Result, not network.
  3. Distinguish abort from other network errors in the catch by checking err.name === "AbortError".
  4. Add badInput and validateCoords; call getWeather(200, 0, fetch) and confirm a badInput Result before any network call happens.
  5. Remove the default block, delete one case, and confirm the function still compiles — demonstrating why the never guard is what makes exhaustiveness enforced rather than merely present.
  6. (Stretch) Introduce branded Latitude/Longitude types with a makeLatitude smart constructor, and change getWeather to require them — making invalid coordinates unrepresentable at the call site.

Official documentation

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