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}¤t=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
- 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. - Delete the
isApiResponsecheck and directly readbody.current.temperature_2m; read the compile error (bodyisunknown) — proof the guard is doing real work. - Feed
toConditionthe codes 0, 2, 61, 95, and 999, and confirm the conditions (clear,cloudy,rain,thunderstorm,unknown). - Swap the hand-written guard for a Zod schema with
z.infer, and confirmgetWeatherstill type-checks and runs — see how much boilerplate disappears. - Try to return
{ ok: true, value: { temperatureC: 20, windSpeedKmh: 5, condition: "rainy" } }and read the error — the closedConditionset at work. - Add a
console.loginside each failure branch and triggerhttpby pointing at a bad URL path; watch the correct Result come back instead of a thrown error.
Official documentation
- MDN — Using the Fetch API — The real
fetchourFetchLikemodels. - TypeScript — Type Predicates — The
value is ApiResponseguard. - Open-Meteo — Forecast API — The endpoint and its parameters.
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