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

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:

  • condition is a union of string literals, not string. This is "make illegal states unrepresentable" (best-practices). A caller can switch on condition and the compiler guarantees the exhaustive set; nobody can pass "drizzling" or "Rainy" where a Condition is expected. A plain string would allow any of infinitely many wrong values.
  • The fields are readonly. A Weather is a snapshot — it describes a moment, and nothing should mutate it after we build it. readonly says so and the compiler enforces it (the immutability lesson).
  • The names carry units. temperatureC, windSpeedKmh — not temperature, speed. The type says number, 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 value without first checking ok. result.value is an error until you narrow with if (result.ok); only inside that branch does value exist. 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 Result is in the return type — the caller sees Result<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 a detail. The types stop you from reading a status on 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 the never trick (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

  1. Write out the full type skeleton above in a types.ts and confirm it type-checks with tsc --noEmit (no bodies needed).
  2. Try to construct a Weather with condition: "drizzle" and read the compile error — illegal state, unrepresentable.
  3. Write const r: Result<Weather> = ... for a success and a failure, then try to read r.value without checking r.ok; read the error, then fix it with a narrow.
  4. Try to read .status on a { kind: "network" } error and confirm the compiler rejects it.
  5. Add a fourth failure variant { kind: "timeout" } to WeatherError and note that nothing breaks yet — the exhaustiveness check that will catch the unhandled case comes in the hardening lesson.
  6. Compare your paper sketch from the previous lesson to this design. Where did you reach for string where a literal union is safer? Where did you miss a failure mode?

Official documentation

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