Guide

OpenAPI to Zod-validated client

An OpenAPI-generated TypeScript client with Zod schemas validating every request and response at the runtime boundary.

Last reviewed: April 23, 2026

Types from OpenAPI are a contract at compile time. They say "the server will return this shape" — and the TypeScript compiler believes them. The problem is that a compiler is not a network. The server can drift from the spec, a proxy can mangle a header, a cached response can come from a build three deploys ago. When the wire lies, a client typed with `as Response` pretends everything is fine and hands the caller an object missing half its fields.

Zod fixes the gap between the type system and the wire by re-doing the check at runtime. A Zod schema is both a TypeScript type and a validator — the same declaration produces the compile-time inference and the runtime parse. If the server returns something the schema doesn't accept, you get a `ZodError` at the SDK boundary, not a silent undefined three call sites later.

SDK Factory emits Zod schemas for every request body, every response shape, every error variant listed in the OpenAPI document. The TypeScript types consumers see are inferred from those schemas — there is no second source of truth, no drift between the type and the validator.

Why the manual path hurts

What you actually spend time on when you wire this by hand — and what eventually drifts.

Compile-time types don't validate wire data

`as UserResponse` tells the compiler the shape. It tells the runtime nothing. When the server returns `{ id: 42, emial: "…" }` (typo intentional), your code reads `user.email` as `undefined` and keeps going. The bug surfaces three screens later as a string concatenation that says "Hello, undefined".

Generated clients emit unvalidated casts

typescript-axios: `response.data as T`. openapi-typescript: types only, no runtime. typescript-fetch: the same cast. The type-checker is happy. The user is the one who sees the broken UI.

Errors collapse into `catch (e: any)`

An endpoint with three documented error variants — `409 EmailTaken`, `422 ValidationError`, `500` — lands in your code as `AxiosError` with a `response.data` of `unknown`. You lose every piece of the contract the spec promised you.

Adding Zod by hand duplicates the spec

Teams who notice the gap often hand-write Zod schemas alongside the generated types. Now the spec exists in two places — the OpenAPI document and a `schemas.ts` file — and they drift the same week the first schema change ships.

Before and after

The hand-rolled version versus the pasted-URL version — same end state, very different footprint.

Doing it by handtypescript-axios output (abridged)
// generated/api.ts — typescript-axios, unchanged from the generator
export interface User {
    id: number;
    email: string;
    role: 'admin' | 'member';
}

export class UsersApi {
    async getUser(id: number): Promise<AxiosResponse<User>> {
        return this.axios.get(`/users/${id}`);
    }
}

// consumer.ts — caller has no runtime protection
const { data: user } = await api.getUser(42);
console.log(user.email.toLowerCase());
// ↑ if server returned { id: 42, emial: "…" }: "undefined.toLowerCase()"
//   runtime error, 6 frames away from the call site. No ZodError.
With SDK FactorySDK Factory output (abridged)
// sdk output — inferred from Zod, single source of truth
const UserSchema = z.object({
    id: z.number(),
    email: z.string().email(),
    role: z.enum(['admin', 'member']),
});
export type User = z.infer<typeof UserSchema>;

export async function getUser(id: number): Promise<User> {
    const res = await fetch(`/users/${id}`);
    if (!res.ok) throw errorForOperation('getUser', res);
    return UserSchema.parse(await res.json());
    //                ^ drift surfaces here as a structured ZodError
}

// consumer.ts — caller gets the typed error union for free
try {
    const user = await api.getUser(42);
    console.log(user.email.toLowerCase());
} catch (err) {
    if (err instanceof EmailTakenError) { /* narrow */ }
    else if (err instanceof ValidationError) { /* narrow */ }
    else if (err instanceof ZodError) { /* server drift */ }
}

What SDK Factory does instead

The same pain points, handled by the pipeline — not by whoever is on call this week.

Request bodies validated before they leave

Every method that takes a body runs it through the Zod schema for the request shape. An invalid body throws a `ZodError` locally, never hits the network, and the caller learns about the bug in the try/catch right next to the call — not from a 400 response logged in the server.

Response bodies validated on arrival

Every response is parsed against the Zod schema for that operation's documented success shape. A server that's drifted from the spec produces a structured `ZodError` with the path of the offending field — not an `undefined` floating through your app.

Errors typed per operation

For each operation, error responses documented in the OpenAPI document are emitted as a discriminated union. `catch (err)` in the SDK narrows to `EmailTakenError | ValidationError | UnknownError` — you pattern-match instead of sniffing `instanceof`.

One source of truth

Types and Zod schemas are inferred from the same source. You never maintain a parallel schemas.ts. Changing the spec changes both in lockstep on the next rebuild.

How Zod fits into the generated client

For every named schema in the OpenAPI `components.schemas`, SDK Factory emits a Zod schema with a matching name. `$ref` chains turn into Zod's `z.lazy()` when they would otherwise cycle. Nullable properties, `oneOf` / `anyOf` / `allOf`, discriminators, enum + const narrowing — each has a Zod equivalent, emitted consistently.

TypeScript types are inferred from those Zod schemas (`z.infer<typeof UserSchema>`) rather than declared separately. That's deliberate: it eliminates the most common bug class of hand-written Zod — the type and the schema disagreeing about a field. Here they cannot, because the type is derived from the schema.

The generated client does both sides of validation at the boundary of the method call. Request bodies are parsed (not `parseAsync`, for speed) before serialisation; response bodies are parsed before return. Nothing unvalidated crosses the SDK boundary.

Typed errors per operation

OpenAPI lets each operation declare its own set of error responses. `createUser` might return `409 EmailTaken` and `422 ValidationError`; `deleteUser` might return `404` and `403`. A generic `AxiosError` loses all of that.

SDK Factory emits one error class per documented response per operation, grouped into a discriminated union that becomes the operation's thrown-error type. A `catch` block narrows to that union. Responses not documented in the spec land as a catch-all `UnknownApiError` so they are never silently swallowed.

The `ZodError` path is kept separate from the API error path: an error parsed from the server's body is an API error; a validation failure on that body is a `ZodError` surfacing a spec/server mismatch. Downstream code can treat them differently — the former is a user-visible situation, the latter is usually a pager-duty situation.

What happens when the server drifts from the spec

The server adding a field the spec doesn't mention: by default, Zod strips unknown keys silently. The consumer is not affected. If you want the stricter behaviour (extra keys become a `ZodError`), the generated schema exposes `.strict()` variants.

The server renaming a field: the schema parse fails with a `ZodError` whose `.path` points at the renamed field. The failure happens at the SDK boundary, not six frames deeper where the `undefined` would otherwise surface.

The server dropping a field entirely: same — a structured `ZodError` with the missing path. Either way, the consumer gets a single, narrow exception type to handle, and the incident is visible in whatever observability you pipe SDK errors through.

Who reaches for this

  • Public API SDKs where the server and the client deploy on different cadences, and a drifted response should surface at the boundary, not in UI.
  • Teams who have already added Zod to the app layer and want the client to produce schemas on the same shape without hand-maintenance.
  • Consumer-side validation of webhook-like responses where a mismatch is an incident, not a warning.
  • Services exposing long-lived integrations where the cost of a silent schema drift is higher than a loud runtime error.

FAQ

Why validate responses at runtime — isn't TypeScript enough?

TypeScript validates the part of the contract you control at compile time. Zod validates the part the network controls at runtime. If your client never talks to a server that's drifted from the spec, compile-time types are fine. In every other case, Zod turns the divergence into a structured error you can handle, instead of a shape mismatch you discover four screens later.

Does the Zod validation cost matter?

For typical payloads (KB-scale JSON, deep nesting under ~10) the overhead is in the low microseconds — unmeasurable against any real network latency. For unusual shapes (multi-MB responses, deep recursive schemas) you can opt into streaming-parse on the body or disable validation on specific operations. The default is 'always on' because the cost of not validating is higher than the cost of validating.

Can I use my own Zod schemas alongside the generated ones?

Yes — every generated schema is a standard Zod schema, so you can compose, extend, or swap them with your app-layer schemas. A common pattern is adding `.brand<'UserId'>()` to generated primitive types for domain modelling.

What if my OpenAPI document uses features Zod can't express?

OpenAPI has a few shapes that don't round-trip cleanly (discriminator with external refs, deep allOf compositions, some format strings). We emit the closest faithful Zod equivalent and document the gap in the generated source. In practice this hits under 1 % of real-world specs — but if it hits yours and the shape matters, file an issue and we'll tune the generator.

Do consumers need Zod as a peer dependency?

Zod ships as a regular dependency of the generated package, not a peer. Consumers don't need to install anything extra to use the SDK. If they already use Zod elsewhere, npm will deduplicate as usual.

Try it with your actual schema.

One app on the Free tier, no card required. Paste your OpenAPI URL and see the generated Zod-validated client in minutes.