Release candidate — 1.0.0-rc.7
← Back to blog

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 {  } from 'zod';

const  = .({ : .(), : .() });
type  = .<typeof >;

async function (: number): <> {
    const  = await (`https://api.example.com/users/${}`);
    if (!.) throw new (`HTTP ${.}`);

    const  = .(await .());
    if (!.) {
        throw new (`Bad /users response: ${..}`);
    }
    return .; // typed as User, validated
}

This is correct, and for a one-off it's the right amount of code. parsed.data is typed by z.infer, the runtime check actually runs, and a renamed or missing field is caught here rather than downstream. What it won't do is hold its shape across more than one call site — which is where it starts to fray.

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 {  } from 'stitchapi';
import {  } from 'zod';

const  = .({ : .(), : .() });

const  = <{ : number; : string }>({
    : 'https://api.example.com',
    : '/users/{id}',
    : ,
});

const  = await ({ : { : 1 } }); // typed · validated

output takes the z.object(...) schema directly; any Standard Schema validator (Valibot, ArkType) or a plain predicate goes on the config the same way. 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 {  } from 'stitchapi';
import {  } from 'zod';

const  = <{ : number; : string }>({
    : 'POST',
    : 'https://api.example.com',
    : '/users',
    : { : .({ : .() }) },
    : .({ : .(), : .() }),
});

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.

Start with safeParse — declare it on the call when it spreads

For a true one-off — one endpoint, run once, no retries or auth in sight — User.safeParse(await res.json()) is the right amount of ceremony. The schema doesn't change when you outgrow that; it just moves. The same Zod object goes on output and runs on every response instead of in a branch you re-type after each fetch, and that move pays off the moment any of these is true:

  • A second call site appears. The res.ok check, the parse, and the error wrapping stop being written once and start being copy-pasted — each free to drift. On a stitch they're declared once and uniform.
  • The call gains a neighbor. When validation sits next to retry, a timeout, or an auth boundary, declaring them together on one call beats bolting each around the fetch by hand.
  • You want the request guarded too. safeParse checks the response; input checks the request before it leaves the process — a boundary the manual pattern doesn't give you.

The schema is the same Zod object either way. The only question is whether it runs as a step you repeat after each fetch or as a property of a call you declare once — and turning one into the other is a move you make when the second caller arrives, not a rewrite.

Try it

npm install stitchapi@rc

Move your safeParse schema straight onto a stitch's output, 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.