30 β Typing API Responses at the Boundary
Level: Intermediate Β· Time: ~35 min Β· Prerequisites: Lesson 29 β Resilience β Timeouts, Retries, Rate Limits and Pagination
Why this matters
Lesson 27's helper ends with return (await response.json()) as T;, and its own notes admit the problem:
that cast is a promise to the compiler, not a check, because types are erased before the program runs. A
response arrives as any, and every type downstream inherits a promise nobody verified. So: parse against a
schema you wrote, map the vendor's shape into your own, and keep a recorded response as a failing test.
The boundary rule
Everything crossing a boundary is unknown until something checks it. A boundary is a network response, a
file on disk, an environment variable, a command-line argument, or a message from another process. Inside
the boundary TypeScript is trustworthy; outside it, a type is a claim.
| What you write | What the checker believes | What happens at 23:00 |
|---|---|---|
await response.json() as DailyYield[] |
that the API sent an array of daily yields | a loop over undefined, or NaN in a chart |
const d: DailyYield[] = await response.json() |
the same lie, written more politely | identical |
const body: unknown = await response.json() |
nothing, and it will not let you read a field | nothing runs until you parse |
schema.parse(body) |
whatever the schema proves | a typed value, or an error naming the field |
Validating at the boundary is not an alternative to types; it is what makes them true β everything inside may then be written as if the data were correct, because it was checked on the way in.
A schema first, and the type derived from it
Lesson 23 introduced zod at the boundary. Write the schema once and derive the type from it, because an
interface beside a hand-written schema is two sources of truth β and the compiler still believes the cast.
// src/api/schemas/nasaPower.ts
import { z } from "zod";
/** The POWER response of Lesson 27, described instead of asserted. */
export const PowerResponseSchema = z.object({
properties: z.object({
parameter: z.record(z.string(), z.record(z.string(), z.number())), // ALLSKY_SFC_SW_DWN: { "20260301": 5.8 }
}),
});
export type PowerResponseDto = z.infer<typeof PowerResponseSchema>;
z.infer is the whole trick: PowerResponseDto is not a second description of the payload, it is the
schema's type, so a field added to the schema is a field on the type. The check itself is one shared function:
// src/api/readJson.ts
import type { z } from "zod";
import { DomainError } from "../errors/domainError.js";
export async function readJson<T>(response: Response, schema: z.ZodType<T>): Promise<T> {
const body: unknown = await response.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
throw new DomainError("API_SCHEMA", "response did not match the expected shape", { issues });
}
return parsed.data;
}
safeParse returns a discriminated union, so if (!parsed.success) narrows to the failure half and
parsed.error.issues is typed, each issue carrying the path that names the offending field. Turning that
into the DomainError("API_SCHEMA", β¦) of Lesson 20 gives every layer above one error type and one log line.
DTO to domain: two objects, not one
The shape the vendor sends and the shape your program wants are different things. Name the wire type for
what it is β a DTO, a data transfer object β and map it once, at the edge, into the DailyYield of Lesson 27.
// src/domain/yields.ts
import type { PowerResponseDto } from "../api/schemas/nasaPower.js";
export interface DailyYield { readonly date: string; readonly kwhPerM2: number; }
export function toDailyYields(dto: PowerResponseDto): DailyYield[] {
const series = dto.properties.parameter.ALLSKY_SFC_SW_DWN ?? {};
return Object.entries(series)
.filter(([, kwhPerM2]) => kwhPerM2 > -900) // POWER's -999 means "no data", not a measurement
.map(([day, kwhPerM2]) => ({ date: `${day.slice(0, 4)}-${day.slice(4, 6)}-${day.slice(6, 8)}`, kwhPerM2 }));
}
| Concern | The DTO (on the wire) | The domain type (inside) |
|---|---|---|
| Field names | ALLSKY_SFC_SW_DWN, dates as YYYYMMDD |
kwhPerM2, ISO dates |
| Missing data | -999, null, or an absent key |
filtered out, or an explicit union |
| Version | endpoint version, header.version |
irrelevant; one stable internal type |
Optional, nullable and versioned
Real APIs are looser than their documentation: a field described as always present may be absent on a dry day. Model that in the schema rather than hoping.
export const YieldRowSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
yieldKwh: z.coerce.number().nonnegative(), // some v1 responses send strings
irradiationKwhM2: z.number().nonnegative().nullable(), // documented as sometimes null
inverterTemperatureC: z.number().optional(), // newer firmware only
source: z.string().default("pvgis"), // absent in older responses
});
Use .nullable() when the API sends an explicit null, .optional() when the key may be missing entirely β
they are not the same, and .optional() alone rejects a null. Use .default() for fields you can supply
sensibly, so one missing value does not fail the response. Treat a sentinel such as -999 as missing in the
mapping step, not in the schema, and note that zod strips unknown keys before they reach your domain type.
Generated types, contract tests, and undocumented APIs
Generating types from an OpenAPI document or a GraphQL schema (the openapi-typescript and graphql-codegen
families, and clients built on them) is genuinely useful: names, nullability and client methods arrive
consistent with the published specification. What generation does not give you is runtime safety, because a
generated type is still a claim about what the server will send, and a server that deploys ahead of its
specification sends something else. Use the generated types as your DTO and validate at the boundary anyway;
contract tests catch upstream drift on your terms:
// test/nasaPower.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { PowerResponseSchema } from "../src/api/schemas/nasaPower.js";
import { toDailyYields } from "../src/domain/yields.js";
test("the recorded March week still maps to usable days", async () => {
const raw = await readFile(new URL("./fixtures/nasa-daily-2026-03.json", import.meta.url), "utf8");
const days = toDailyYields(PowerResponseSchema.parse(JSON.parse(raw) as unknown));
assert.equal(days[0]?.date, "2026-03-01");
assert.ok(days.length <= 7); // sentinel days are dropped, so seven recorded days can yield fewer
assert.ok(days.every((day) => day.kwhPerM2 >= 0));
});
Commit the raw response unmodified β it is evidence, not something to edit into shape β and re-record it periodically: the diff in the fixture is your early warning.
[!TIP] When an API is undocumented, probe it rather than guessing. Call it with
curl -i, save the body intotest/fixtures/, read the JSON by eye, and write the schema you observed. Mark anything you have seen only once as.optional(), and comment the endpoint and the date you recorded it.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
as T on response.json(), as the Lesson 27 helper does |
The checker is silenced precisely where nothing is known | Parse with a schema before anything reads a field |
Writing the DTO as an interface and validating nothing |
Two descriptions of one payload, and the code trusts the wrong one | Schema first, z.infer for the type |
| Using the DTO throughout the codebase | One vendor rename ripples through every module | Map to a domain type at the edge, once |
| Marking every field required | One missing optional value fails the response and you lose the good days | .optional(), .nullable() or .default() |
Plotting -999, 0 or "" as measurements |
Nonsense rows that look like data in a chart | Handle sentinels in the mapping step |
| Validating only in tests | Drift is discovered by the household, not by CI | Validate at runtime at the boundary |
| Trusting generated types as runtime safety | Codegen types silently lie when the server changes first | Validate the response; let codegen provide the shape |
Try it
- Install zod and record a real response β the NASA POWER endpoint needs no key:
npm install zod
mkdir -p test/fixtures
curl -s "https://power.larc.nasa.gov/api/temporal/daily/point?parameters=ALLSKY_SFC_SW_DWN&community=RE&longitude=-17.44&latitude=14.69&start=20260301&end=20260307&format=JSON" \
-o test/fixtures/nasa-daily-2026-03.json
Expected output: a file of a few kilobytes. Find the nested properties.parameter object in it before writing any code β reading the real payload is what makes the schema honest. If curl cannot reach the internet, reuse the response you recorded in Lesson 27.
- Write
src/api/schemas/nasaPower.ts,src/api/readJson.ts,src/domain/yields.tsandtest/nasaPower.test.tsas above, then runnpx tsc && node --test dist/.
Expected output: # pass 1, # fail 0. If an assertion does not match, count the days in the fixture
and adjust it, with a comment saying why that number is right.
- Prove the schema is doing work: copy the fixture, change one value from a number to the string
"n/a", point a temporary test at the copy, and run again.
Expected output: a DomainError with code API_SCHEMA whose context.issues contains the path properties.parameter.ALLSKY_SFC_SW_DWN.20260301: Expected number, received string β the message that turns an afternoon of undefined into a five-minute fix.
Key takeaways
- Everything crossing a boundary is
unknown: a cast on afetchresult is a promise you cannot keep, because the checker is erased before the runtime sees the data. - Write the schema once and derive the type with
z.infer, so the schema and the type cannot drift. - The DTO and the domain type are separate objects: names, units, sentinels and defaults are resolved in one mapping function.
- Code generation gives shapes, not runtime safety; golden fixtures turn an upstream change into a failing test.
Check yourself
- Why does
await response.json() as Tneed validation even though the enclosing function is annotated to returnDailyYield[]? - You write
interface PowerResponseDtoby hand and a zod schema. What goes wrong six months later? - The API sends
irradiationKwhM2: nullon a cloudy day, but your schema uses.optional(). What happens, and what is the correct declaration?