Testing typed code, including the types themselves
The client works and is hardened. The last discipline is proving it stays that way — tests. Typed code needs two kinds of test, and this lesson writes both: runtime tests that exercise every path against fake responses (no live network), and type-level tests that assert the types themselves are what we intend. The dependency injection we built in from the start is what makes the first kind possible without hitting Open-Meteo, and the second kind is a technique unique to typed languages that catches a whole class of regression the runtime tests never can.
Why the injected fetch was the whole point
Recall that getWeather takes fetchFn: FetchLike rather than calling the global fetch. In production
you pass the real fetch; in a test you pass a fake that returns whatever response you want to test
against. This is the payoff of the design decision — we can drive every branch of the client deterministically,
with no network, no rate limits, and no flaky "the API was slow today" failures:
type FetchLike = (url: string) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
// A fake for each scenario we want to test:
const fakeOk: FetchLike = async () => ({
ok: true, status: 200,
json: async () => ({ current: { temperature_2m: 27, wind_speed_10m: 5, weather_code: 61 } }),
});
const fakeHttp: FetchLike = async () => ({ ok: false, status: 503, json: async () => ({}) });
const fakeBad: FetchLike = async () => ({
ok: true, status: 200,
json: async () => ({ current: { temperature_2m: "hot" } }), // wrong type — should be rejected
});
Each fake satisfies FetchLike, so getWeather accepts it exactly as it accepts the real thing. A test
that had to call the live API would be slow, non-deterministic, and unable to force a 503 or a malformed
body on demand — the fakes let us test precisely the cases that matter, especially the failures.
Runtime tests: exercise every path
We use Node's built-in test runner (node:test and node:assert, no dependency to install) — a Vitest or
Jest suite looks almost identical. The rule: test the happy path and every failure path, because the
failures are where the client's value lives.
import { test } from "node:test";
import assert from "node:assert/strict";
test("maps a successful response to Weather", async () => {
const r = await getWeather(19, 72, fakeOk);
assert.equal(r.ok, true);
if (r.ok) { // narrow before reading value
assert.equal(r.value.temperatureC, 27);
assert.equal(r.value.condition, "rain"); // weather_code 61 → rain
}
});
test("an error status becomes an http failure", async () => {
const r = await getWeather(0, 0, fakeHttp);
assert.equal(r.ok, false);
if (!r.ok) assert.deepEqual(r.error, { kind: "http", status: 503 });
});
test("a malformed body becomes a badShape failure", async () => {
const r = await getWeather(0, 0, fakeBad);
assert.equal(r.ok, false);
if (!r.ok) assert.equal(r.error.kind, "badShape");
});
Run with npx tsx --test <file>.ts and all three pass. Two things worth noticing:
- The
if (r.ok)narrowing appears in the tests too. The same discriminant that protects real callers protects the test — you cannot readr.valuewithout checkingr.ok, so the test is written the way a real consumer would be. The types keep even the test honest. - We test the failures as first-class behaviour.
fakeBadreturns atemperature_2mof the wrong type — the exact thing the boundary guard exists to catch — and we assert we get abadShapeResult rather than a crash. This is the test that would have failed if we had usedas ApiResponseinstead of a real guard, which is precisely why we did not.
Also test the pure helpers directly — toCondition is a pure function of a number, trivial to test
exhaustively:
test("toCondition maps WMO codes", () => {
assert.equal(toCondition(0), "clear");
assert.equal(toCondition(2), "cloudy");
assert.equal(toCondition(61), "rain");
assert.equal(toCondition(999), "unknown"); // the total-function guarantee, checked
});
Type-level tests: asserting the types, not the values
Here is a technique with no equivalent in JavaScript. The runtime tests check that the code behaves
correctly. But the client's guarantees are largely in the types — that condition is a closed union,
that getWeather returns a Result, that Weather fields are numbers. A refactor could break one of
those type guarantees while every runtime test still passes. Type-level tests catch that, using two tiny
helpers from the advanced-types module:
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
Equal<A, B> is true only if A and B are the same type, and Expect<T> compiles only if T is
true. Together they let you assert a fact about your types that the compiler checks on every build:
type _1 = Expect<Equal<ReturnType<typeof toCondition>, Condition>>; // toCondition returns exactly Condition
type _2 = Expect<Equal<Weather["temperatureC"], number>>; // the field is a number
// @ts-expect-error — condition is a closed union, NOT plain string
type _3 = Expect<Equal<Weather["condition"], string>>;
_1 and _2 compile silently — the assertions hold. _3 asserts something false (that condition is
string), so Equal is false, Expect rejects it, and @ts-expect-error consumes that error — which
means if someone ever loosened condition to string, the @ts-expect-error would have nothing to
suppress and itself become an error. The whole file type-checking clean is the test passing. Now, if a
careless refactor changed toCondition to return string, or made temperatureC optional, the build
breaks — a regression in a type guarantee, caught with zero runtime code.
This is worth internalising as the capstone's closing idea: in a typed language, some of your most important guarantees are types, so some of your tests should be type-level. The runtime tests prove the behaviour; the type tests prove the shape the behaviour depends on.
What a complete test suite for this client looks like
Pulling it together, the client is well-tested when you have:
- A success test — a valid fake response maps to the right
Weather(temperature, wind, condition). - A test per failure kind —
http,badShape,network,timeout,badInput— each fake triggers its Result, proving the failure is handled, not thrown. - Pure-helper tests —
toConditionacross representative codes including an unknown one;validateCoordsacross in- and out-of-range inputs. - Type-level assertions —
Conditionis the closed union,Resultis whatgetWeatherreturns,Weatherfields have the intended types. - Optionally, one real integration test — a single live call to Open-Meteo, kept separate and run rarely, to confirm the API still returns the shape we validate against. (If Open-Meteo renamed a field, only this test would notice — the fakes cannot, because you wrote them to match your expectation.)
That last point is the honest limitation of fakes and worth stating plainly: fakes test that your code handles the responses you imagined; they cannot tell you the real API changed. So the bulk of the suite is fast, deterministic fakes, with one slow, real check guarding the boundary assumption itself. That balance — many isolated tests, a few integration tests — is how professional codebases are tested, and building the client with injected dependencies is what made it achievable.
The capstone, complete
You have built a real typed boundary: a client that takes untrusted input and untrusted API data and
produces a value the rest of a program can trust absolutely — every failure modelled, every new failure
forced to be handled, every guarantee tested at both the value and the type level. Every module of the
course is in it: literal unions and readonly for the domain, discriminated unions and narrowing for the
Result and errors, generics for Result<T>, unknown and type guards for the boundary, async for the
fetch, dependency injection for testability, and the type-level machinery for the tests. That combination —
not any single feature — is what it means to know TypeScript, and it is what will let you walk into real
project work and build things that hold up.
Check your work
Why the injected fetch matters for testing. It lets each test pass a fake response, driving every
branch deterministically with no live network, rate limits, or flakiness.
Which paths to test at runtime. The happy path and every failure path — the failures are where the
client's value is; test that a wrong-typed field yields badShape, not a crash.
Why the tests use if (r.ok) narrowing. The same discriminant that protects real callers — you cannot
read value without checking ok, so the test mirrors real consumption.
What type-level tests assert, and why. Facts about the types (closed Condition, getWeather's
return, field types) that runtime tests cannot — a refactor can break a type guarantee while behaviour
still passes.
How Expect<Equal<A, B>> works. Equal is true only when the types are identical; Expect compiles
only for true; the file type-checking clean is the test passing. @ts-expect-error asserts a type fact is
false.
The limit of fakes, and the fix. Fakes only test responses you imagined — they cannot detect the real API changing; one occasional live integration test guards that boundary assumption.
The five parts of a complete suite. Success test, a test per failure kind, pure-helper tests, type-level assertions, and one live integration test.
Practice
- Write the three runtime tests above plus a
toConditiontest; run withnpx tsx --testand confirm all pass. - Add fakes and tests for the
networkandtimeoutfailures (a fake that throws; one that never resolves within the timeout). Confirm each yields the right Result. - Add the
Equal/Expecthelpers and write three type-level assertions about your client's types; break one deliberately (loosenconditiontostring) and watch the build fail. - Use
@ts-expect-errorto assert thatWeather["condition"]is notstring; then actually change it tostringand confirm the@ts-expect-errorline itself becomes an error. - Write one real integration test that calls Open-Meteo live and asserts the Result is
ok; mark it so it runs separately from the fast suite, and reason about why it belongs apart. - Deliberately reintroduce
as ApiResponsein place of the guard, and confirm thebadShapetest now fails (or crashes) — proving what the guard and its test are protecting. - Review your finished client against the five requirements from the brief. For each, name the test — runtime or type-level — that proves it holds.
Official documentation
- Node.js — Test runner — The built-in
node:testused here; no install needed. - Vitest — Getting started — The popular alternative; the tests port almost unchanged.
- type-fest — IsEqual — A production-grade version of the
Equalhelper for type-level tests.
That is the TypeScript Foundation course. You have the type system, the async model, the practices, the patterns, and a real project that ties them together — enough to do the job, including the hard parts.
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