Validate an API Response With Zod
Oleksandr Zhuravlov
Validate an API response with Zod when you want a malformed or unexpected shape to fail loudly at the boundary instead of leaking through as an undefined three layers downstream. You reach for it the moment you stop trusting that the JSON on the wire matches the type you wrote — which is every third-party API and most internal ones.
The generic way: parse the JSON through a schema
Zod's own answer is direct. Define a schema, fetch, and run the parsed JSON through it. Use safeParse so a bad shape is a value you handle, not a thrown ZodError you forgot to catch:
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
type User = z.infer<typeof User>;
async function getUser(id: number): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const parsed = User.safeParse(await res.json());
if (!parsed.success) {
throw new Error(`Bad /users response: ${parsed.error.message}`);
}
return parsed.data; // typed as User, validated
}This is correct and you should reach for it for a one-off. parsed.data is typed by z.infer, the runtime check actually runs, and a renamed or missing field is caught here rather than downstream. For a single call in a script, stop here — anything more is overhead.
The rough edges show up once you have more than one endpoint. The res.ok check, the safeParse, the error wrapping, and the JSON parse repeat at every call site, each free to drift from the others. Input is unvalidated — you can still send a malformed body and learn about it from the API's 400. And the schema only guards the response body; the status check, retries on a flaky upstream, a timeout, and an auth header are all separate concerns you bolt on by hand around each fetch.
The stitch way: declare the schema once on the call
A stitch makes the schema a property of the call instead of code you run after it. You hand the response schema to output and pair it with a generic; the validator runs against every response, and the rest of the boilerplate disappears:
import { stitch, toValidator } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const getUser = stitch<{ id: number; name: string }>({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
output: toValidator(User),
});
const user = await getUser({ params: { id: 1 } }); // typed · validatedoutput is typed as a Validator, and a raw z.object(...) isn't structurally one — toValidator() adapts a Zod schema (or any Standard Schema validator, like Valibot or ArkType, or a plain predicate) into the type the config expects. The stitch<{ id: number; name: string }> generic types the resolved value, so user is { id: number; name: string } with no z.infer call. A response that doesn't match throws STITCH_VALIDATION at the boundary — the same failure your safeParse branch produced, now uniform across every endpoint instead of re-typed at each one.
Validate the request too
The manual pattern guards the response; the stitch guards both ends. input is a map of { params, query, body, headers }, each an optional validator that runs before the request leaves the process — so a bad body fails fast with no wasted network call:
import { stitch, toValidator } from 'stitchapi';
import { z } from 'zod';
const createUser = stitch<{ id: number; name: string }>({
method: 'POST',
baseUrl: 'https://api.example.com',
path: '/users',
input: { body: toValidator(z.object({ name: z.string() })) },
output: toValidator(z.object({ id: z.number(), name: z.string() })),
});A malformed body rejects before the POST is sent; a response that doesn't match output rejects after. Both raise STITCH_VALIDATION, and the input keys are documented in the validation guide.
Don't reach for a strict schema to catch upstream changes
One trap worth naming: a strict output schema seems like a good drift alarm, but it's the wrong tool. An additive change — a new field the provider adds — then throws STITCH_VALIDATION and breaks a call that should have kept working. When you want to notice a response changing shape without failing on harmless additions, wrap the schema in drift() instead, which validates and then reports differences as non-fatal findings. The reasoning is in schema drift detection and what is schema drift.
When the plain safeParse is enough
The stitch buys you uniformity and an input boundary; you don't always need them.
- A single call in a script. One endpoint, run once, no resilience or auth concerns —
User.safeParse(await res.json())is the right amount of ceremony. Declaring a stitch would be more setup for the same check. - You're not adding retries, timeouts, or auth. A stitch's value compounds when validation sits next to
retry,timeout, and an auth boundary on the same declaration. If the response check is genuinely all you need and the endpoint never flakes, the inline parse is leaner. - You won't reuse the schema. The declaration pays off across many call sites that share concerns. For a throwaway, the manual parse is fewer moving parts.
The line is reuse and surrounding concerns, not the validation itself — the schema is the same Zod object either way. The question is whether it runs as a step you write after each fetch or as a property of a call you declare once.
Try it
npm i stitchapiMove your safeParse schema onto a stitch's output with toValidator(), pair it with the stitch<T> generic, and every call validates its response with a typed error and no per-call-site boilerplate. The mechanics are in Validation, the validator adapter in Standard Schema, and getting types without a codegen step in a type-safe API client without codegen.