Skip to content

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 into test/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

  1. 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.

  1. Write src/api/schemas/nasaPower.ts, src/api/readJson.ts, src/domain/yields.ts and test/nasaPower.test.ts as above, then run npx 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.

  1. 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 a fetch result 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

  1. Why does await response.json() as T need validation even though the enclosing function is annotated to return DailyYield[]?
  2. You write interface PowerResponseDto by hand and a zod schema. What goes wrong six months later?
  3. The API sends irradiationKwhM2: null on a cloudy day, but your schema uses .optional(). What happens, and what is the correct declaration?

Next

Lesson 31 β€” Databases and SQL Fundamentals