RizTech Academy logo
RizTech Academy
Capstone: A Typed Library and ClientLesson 3 of 545 min

Building the client with generics and narrowing

The design is fixed, so building is now mostly a matter of satisfying types we already agreed on. This lesson writes the working client: constructing the request, validating the untrusted response into our domain model, and mapping the WMO code to a condition. By the end you will have a getWeather that returns a real, typed Weather from the live API — the happy path, done properly.

Making the client testable from the start: inject fetch

Before the logic, one design decision that shapes everything: we do not call the global fetch directly. We accept it as a parameter. This is dependency injection (design-patterns), and doing it now — not retrofitting it later — is what makes the testing lesson possible without a network:

// The slice of fetch's contract we actually use — small on purpose.
type FetchLike = (url: string) => Promise<{
  ok: boolean;
  status: number;
  json(): Promise<unknown>;
}>;

Note json() returns Promise<unknown>, not Promise<any> — the honest type for data we have not checked (typing-apis). The real fetch satisfies this shape, so callers pass the global fetch in production; a test passes a fake. We type FetchLike as the minimum we need rather than importing the full Response type, because a smaller contract is easier to fake and states exactly what the function depends on.

Step 1: build the request URL

Straightforward, and a good place for a template literal:

function buildUrl(latitude: number, longitude: number): string {
  return (
    `https://api.open-meteo.com/v1/forecast?latitude=${latitude}` +
    `&longitude=${longitude}&current=temperature_2m,wind_speed_10m,weather_code`
  );
}

Nothing clever — but note the parameters are typed number, so a caller cannot pass a string where a coordinate belongs, and the interpolation is safe because both are numbers. (A production version would guard the coordinate ranges — latitude -90..90, longitude -180..180 — and the hardening lesson returns to that idea. For now, well-typed is enough.)

Step 2: validate the untrusted response

This is the heart of the client and the whole point of the typing-apis lesson. json() gives us unknown; we must prove it is an ApiResponse before we read it, with a type guard:

function isApiResponse(value: unknown): value is ApiResponse {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  const c = v.current;
  if (typeof c !== "object" || c === null) return false;
  const cur = c as Record<string, unknown>;
  return (
    typeof cur.temperature_2m === "number" &&
    typeof cur.wind_speed_10m === "number" &&
    typeof cur.weather_code === "number"
  );
}

Read what this does: it checks the value is a non-null object, that it has a current that is also a non-null object, and that current has all three numeric fields. Only if every check passes does it return true, and the value is ApiResponse predicate then lets the compiler narrow the unknown to a real ApiResponse at the call site. The as Record<string, unknown> steps are the safe use of as — we are not asserting the final shape, only saying "treat this confirmed-object as an index-able bag while I inspect its keys", which is exactly what a guard is allowed to do (avoid-assertions covered this distinction). If any field is missing or the wrong type, we return false and the caller produces a badShape error — never a crash.

(In a real project you would reach for Zod here — z.object({ current: z.object({ ... }) }) gives you this guard and the type from one schema, as the typing-apis lesson showed. We hand-write it in the capstone so the mechanism is visible; the practice asks you to swap in Zod.)

Step 3: map the WMO code to a condition

The API gives an opaque number; our domain wants a Condition. A pure mapping function:

function toCondition(code: number): Condition {
  if (code === 0) return "clear";
  if (code >= 1 && code <= 3) return "cloudy";
  if (code === 45 || code === 48) return "fog";
  if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return "rain";
  if ((code >= 71 && code <= 77) || (code >= 85 && code <= 86)) return "snow";
  if (code >= 95 && code <= 99) return "thunderstorm";
  return "unknown";
}

The return type is Condition, so the compiler guarantees every branch returns a member of our closed set — you cannot accidentally return "rainy". And the final return "unknown" handles every code we did not enumerate, which is why "unknown" earned its place in the union: the function is total, defined for every possible number, with no gap for a crash to slip through.

Step 4: assemble getWeather

Now the pieces compose into the function whose signature we fixed in the design lesson. This lesson wires the happy path and a first cut of the failures (the next lesson hardens them):

async function getWeather(
  latitude: number,
  longitude: number,
  fetchFn: FetchLike,
): Promise<Result<Weather>> {
  let res: Awaited<ReturnType<FetchLike>>;
  try {
    res = await fetchFn(buildUrl(latitude, longitude));
  } catch {
    return { ok: false, error: { kind: "network" } };   // never reached the server
  }

  if (!res.ok) {
    return { ok: false, error: { kind: "http", status: res.status } };  // server said no
  }

  const body: unknown = await res.json();               // untrusted
  if (!isApiResponse(body)) {
    return { ok: false, error: { kind: "badShape", detail: "missing current fields" } };
  }

  return {
    ok: true,
    value: {
      temperatureC: body.current.temperature_2m,        // safe — body narrowed to ApiResponse
      windSpeedKmh: body.current.wind_speed_10m,
      condition: toCondition(body.current.weather_code),
    },
  };
}

Trace the types through it. fetchFn(...) might reject, so the try/catch turns a rejected promise into a network Result. res.ok false becomes an http Result carrying the status. res.json() is unknown, so we cannot touch it until isApiResponse narrows it — and after the guard, body.current.temperature_2m is a known number, no assertion needed. The final object is a Weather because every field matches, and toCondition guarantees a valid condition. Every return is a valid Result<Weather>; the compiler confirms there is no path that returns anything else.

The Awaited<ReturnType<FetchLike>> for res is a small flourish from the advanced-types module — rather than restate the response shape, we derive it from FetchLike itself, so the two can never drift.

Running it against the real API

In production you pass the global fetch:

const result = await getWeather(19.07, 72.87, fetch);   // Mumbai
if (result.ok) {
  console.log(`${result.value.temperatureC}°C, ${result.value.condition}`);
} else {
  console.log(`Failed: ${result.error.kind}`);
}

Run it with npx tsx and you will see live weather — a real number and a real condition, produced from an untrusted JSON response that we validated before trusting. The happy path is complete and honest. What is not yet complete is how gracefully we handle and communicate the failures — every branch returns a correct Result, but a caller still has to interpret each kind by hand, and nothing stops us adding a fourth failure and forgetting to handle it. That is the hardening lesson.

Check your work

Why fetch is injected as FetchLike. So the client is testable without the network; a small hand-written contract (with json(): Promise<unknown>) is easier to fake than the full Response.

Why json() is Promise<unknown>, not any. It is data we have not checked — unknown forces validation before use.

What the type guard does. Checks the value is a non-null object with a non-null current holding three numeric fields; the value is ApiResponse predicate narrows unknown to ApiResponse only when all pass.

Why the as Record<string, unknown> inside the guard is acceptable. It is the safe use of as — treating a confirmed object as index-able while inspecting keys, not asserting the final shape.

Why toCondition is total. Every branch returns a Condition, and the final "unknown" covers every unenumerated code — no gap, no crash; the return type enforces the closed set.

How the types flow through getWeather. try/catch → network; !res.ok → http; unknown body guarded → badShape or, after narrowing, a valid Weather. Every return is a valid Result<Weather>.

What Awaited<ReturnType<FetchLike>> buys. The response type is derived from FetchLike, so it cannot drift from the contract.

Practice

  1. Assemble all the pieces into one file and run getWeather(yourLat, yourLon, fetch) against the real API; print the temperature and condition for your city.
  2. Delete the isApiResponse check and directly read body.current.temperature_2m; read the compile error (body is unknown) — proof the guard is doing real work.
  3. Feed toCondition the codes 0, 2, 61, 95, and 999, and confirm the conditions (clear, cloudy, rain, thunderstorm, unknown).
  4. Swap the hand-written guard for a Zod schema with z.infer, and confirm getWeather still type-checks and runs — see how much boilerplate disappears.
  5. Try to return { ok: true, value: { temperatureC: 20, windSpeedKmh: 5, condition: "rainy" } } and read the error — the closed Condition set at work.
  6. Add a console.log inside each failure branch and trigger http by pointing at a bad URL path; watch the correct Result come back instead of a thrown error.

Official documentation

Next: hardening — handling every failure exhaustively.

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