Designing the types and the public API
Before we write a single line of logic, we design the types. This is not a formality — in a typed language the types are the design, and getting them right is what makes the implementation fall out almost automatically. This lesson produces the complete type skeleton of the weather client: the value we return, the closed set of conditions, and the way we represent success and every kind of failure. No function bodies yet — just the shapes, and the reasoning behind each choice.
Start from the output: the Weather value
Design outside-in. What does a caller want back? A temperature, a wind speed, and a human-meaningful condition — nothing about HTTP or JSON or WMO codes. So:
type Condition =
| "clear" | "cloudy" | "fog" | "rain" | "snow" | "thunderstorm" | "unknown";
interface Weather {
readonly temperatureC: number;
readonly windSpeedKmh: number;
readonly condition: Condition;
}
Three deliberate decisions, each from the course:
conditionis a union of string literals, notstring. This is "make illegal states unrepresentable" (best-practices). A caller canswitchonconditionand the compiler guarantees the exhaustive set; nobody can pass"drizzling"or"Rainy"where aConditionis expected. A plainstringwould allow any of infinitely many wrong values.- The fields are
readonly. AWeatheris a snapshot — it describes a moment, and nothing should mutate it after we build it.readonlysays so and the compiler enforces it (the immutability lesson). - The names carry units.
temperatureC,windSpeedKmh— nottemperature,speed. The type saysnumber, but the name says which number, so a caller cannot confuse Celsius for Fahrenheit or km/h for m/s. Types plus good names, together.
Notice we included "unknown" in Condition. That is honesty: the WMO code set is large, we map the
common ranges, and anything unrecognised becomes "unknown" rather than a lie or a crash. Modelling the
"we do not know" case explicitly is better than pretending every code fits a neat bucket.
Represent success-or-failure: the Result type
The client can fail, and requirement 3 says a failure must be a value, not a thrown exception a caller might forget to catch. That is the Result pattern (best-practices, async-error-handling). We use a discriminated union:
type Result<T, E = WeatherError> =
| { ok: true; value: T }
| { ok: false; error: E };
The ok boolean is the discriminant. This one shape gives us two guarantees the course spent whole lessons
on:
- You cannot read
valuewithout first checkingok.result.valueis an error until you narrow withif (result.ok); only inside that branch doesvalueexist. The type makes "used the result before checking it succeeded" impossible. - You cannot ignore the failure. Unlike a thrown exception, which is invisible in the function's type,
a
Resultis in the return type — the caller seesResult<Weather>and must deal with both arms.
The default type parameter E = WeatherError means callers usually just write Result<Weather>, and the
error type is filled in. Generic, but with a sensible default — the generics module's lesson on not making
callers spell out what can be inferred.
Model the failures: a typed WeatherError
Now the failures themselves. The brief named three, and each is genuinely different, so each is its own
variant of a discriminated union — not a bare string message, not an Error with a stringly-typed
.message a caller has to parse:
type WeatherError =
| { kind: "network" } // never reached the server
| { kind: "http"; status: number } // server responded, but with an error status
| { kind: "badShape"; detail: string }; // response arrived, but not in a shape we trust
Why a discriminated union rather than one error type with an optional-everything shape:
- Each variant carries exactly the data that failure has, and no more. A network failure has nothing to
add. An HTTP failure has a
status. A shape failure has adetail. The types stop you from reading astatuson a network error, because there is not one — a bug the compiler catches. - The caller can handle each differently, exhaustively. A
switch (error.kind)gets full autocomplete and, with thenevertrick (next lesson), a compile error if we ever add a fourth failure and forget to handle it. This is the unions-and-narrowing module's payoff.
Compare the JavaScript habit — throw new Error("HTTP 503") — where the status is buried in a string, the
"network vs bad-shape" distinction is lost, and the caller must try/catch and string-match to react. The
typed union is strictly more informative and strictly safer.
Model the input we do not trust: the raw API shape
One more type, and it is a different kind of type. Everything above is our clean domain. But the JSON from Open-Meteo is untrusted (typing-apis) — we must describe the part we consume so we can validate against it, while being clear this is the boundary shape, not our domain:
// The subset of the Open-Meteo response we read. NOT our domain type —
// this is the untrusted incoming shape, validated before we believe it.
interface ApiResponse {
current: {
temperature_2m: number;
wind_speed_10m: number;
weather_code: number;
};
}
Keeping ApiResponse (snake_case, the API's names) separate from Weather (our clean names) is
deliberate: the boundary shape and the domain shape are different concerns, and mapping between them is
where we translate untrusted external structure into trusted internal structure. If Open-Meteo renamed a
field tomorrow, only ApiResponse and the mapping change — Weather and every caller are untouched. That
insulation is the point of having two types instead of using the API's shape throughout.
The complete skeleton, and the signature it implies
Put together, the design is:
type Condition = "clear" | "cloudy" | "fog" | "rain" | "snow" | "thunderstorm" | "unknown";
interface Weather { readonly temperatureC: number; readonly windSpeedKmh: number; readonly condition: Condition; }
type WeatherError = { kind: "network" } | { kind: "http"; status: number } | { kind: "badShape"; detail: string };
type Result<T, E = WeatherError> = { ok: true; value: T } | { ok: false; error: E };
interface ApiResponse { current: { temperature_2m: number; wind_speed_10m: number; weather_code: number } }
And this design dictates the function's signature before we write its body:
function getWeather(latitude: number, longitude: number): Promise<Result<Weather>>;
Read that signature and you already know the whole contract: it takes two numbers, it is async, it returns
a Result that is either a Weather or a WeatherError — it cannot throw a surprise, cannot return a
half-value, cannot hand back an unchecked API shape. We have not written any logic, and the design is
already almost impossible to misuse. That is what "the types are the design" means, and building the body
in the next lesson is largely a matter of satisfying types we have already fixed.
Check your work
Why Condition is a literal union, not string. To make illegal states unrepresentable — only the
known conditions are valid, and callers can switch exhaustively.
Why Weather fields are readonly and unit-named. It is an immutable snapshot; temperatureC/
windSpeedKmh put the unit in the name so a number cannot be misread.
Why include "unknown" in Condition. Honesty — unrecognised WMO codes map to "unknown" rather than
a lie or a crash.
What the Result type guarantees. You cannot read value without checking ok, and the failure is in
the return type so it cannot be silently ignored — unlike a thrown exception.
Why WeatherError is a discriminated union. Each failure carries exactly its own data (a network error
has no status), and callers can handle each exhaustively.
Why ApiResponse is separate from Weather. The untrusted boundary shape and the trusted domain shape
are different concerns; separating them insulates callers from API changes.
What "the types are the design" means. The designed types dictate the function signature and reject misuse before any logic is written.
Practice
- Write out the full type skeleton above in a
types.tsand confirm it type-checks withtsc --noEmit(no bodies needed). - Try to construct a
Weatherwithcondition: "drizzle"and read the compile error — illegal state, unrepresentable. - Write
const r: Result<Weather> = ...for a success and a failure, then try to readr.valuewithout checkingr.ok; read the error, then fix it with a narrow. - Try to read
.statuson a{ kind: "network" }error and confirm the compiler rejects it. - Add a fourth failure variant
{ kind: "timeout" }toWeatherErrorand note that nothing breaks yet — the exhaustiveness check that will catch the unhandled case comes in the hardening lesson. - Compare your paper sketch from the previous lesson to this design. Where did you reach for
stringwhere a literal union is safer? Where did you miss a failure mode?
Official documentation
- TypeScript — Discriminated Unions — The pattern behind
ResultandWeatherError. - TypeScript — Literal Types — Why the
Conditionunion is safer thanstring. - TypeScript — readonly properties — The immutable snapshot.
Next: building the client — request, validate, map.
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