TypeScript and JavaScript · Intermediate
Validate data at the boundary with zod
Why it matters
Integrations break at their edges: an API changes a field, returns an error object instead of data, or sends null where you expected a number. TypeScript types cannot catch this, because they are erased before the code runs. A schema validator such as zod checks the real data at runtime and gives you the static type from the same definition.
The concept
Define a schema for the data you expect.
Derive the type from the schema with
z.infer, so the two never drift apart.Parse at the boundary: every response, file, webhook body or environment variable that comes from outside your code.
Fail clearly:
safeParsereturns either the data or a detailed error, so you can report which field was wrong.
Also set a timeout on every network call. A hung request with no timeout is one of the most common causes of stuck integrations.
Worked example: a validated World Bank client
The World Bank API returns [metadata, rows] on success, but [{ message: [...] }] for an invalid indicator, with HTTP status 200. A type assertion would let that through; a schema does not.
import { z } from "zod";
const Observation = z.object({
countryiso3code: z.string().length(3),
date: z.string().regex(/^\d{4}$/),
value: z.number().nullable(),
});
const Response = z.tuple([z.object({ total: z.number() }).loose(), z.array(Observation)]);
type Observation = z.infer<typeof Observation>;
export async function getSeries(indicator: string, country: string, from: number, to: number): Promise<Observation[]> {
const url = new URL(`https://api.worldbank.org/v2/country/${country}/indicator/${indicator}`);
url.search = new URLSearchParams({ format: "json", date: `${from}:${to}` }).toString();
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`HTTP ${response.status} from ${url.host}`);
const parsed = Response.safeParse(await response.json());
if (!parsed.success) {
throw new Error(`Unexpected response shape: ${z.prettifyError(parsed.error)}`);
}
return parsed.data[1];
}Using it (zod 4, installed with npm install zod):
import { getSeries } from "./validated.ts";
const rows = await getSeries("NY.GDP.PCAP.CD", "IND", 2021, 2023);
console.table(rows.map((r) => ({ year: r.date, usd: r.value?.toFixed(0) })));
await getSeries("XX.BAD", "IND", 2021, 2023); // throws: Unexpected response shape$ node main.ts
┌─────────┬────────┬────────┐
│ (index) │ year │ usd │
├─────────┼────────┼────────┤
│ 0 │ '2023' │ '2434' │
│ 1 │ '2022' │ '2280' │
│ 2 │ '2021' │ '2240' │
└─────────┴────────┴────────┘
Error: Unexpected response shape: ✖ Too small: expected array to have >=2 itemsThe real API answered the bad indicator with [{"message":[{"id":"120","key":"Invalid value",...}]}]. Without validation the code would have crashed later with "cannot read properties of undefined"; with it, the error says exactly what was wrong and where.
.loose() on the metadata object allows extra fields you do not care about. Keep schemas strict on the fields you use and loose on the rest, so harmless API additions do not break you.
Common mistakes
Validating only in development. Validation is most valuable in production, where the surprises happen.
Writing the type and the schema separately. They drift. Derive the type with
z.infer.parsein a place that cannot handle an exception. UsesafeParseand decide explicitly what happens on failure.No timeout. Always pass an
AbortSignal.timeoutor equivalent.
Interview angle
"How do you make an integration with a third-party API robust?" Mention validation at the boundary, timeouts, retries with backoff for transient errors (not for 4xx), idempotency, logging the request id, and alerting on validation failures because they often mean the API changed.
Practice task
Add a
getCountries()function that validates the/v2/country?format=jsonresponse.Add retries with exponential backoff for network errors and 5xx responses only, and test it by pointing the client at an address that refuses connections.