Guide
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.
What you actually spend time on when you wire this by hand — and what eventually drifts.
`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".
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.
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.
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.
The hand-rolled version versus the pasted-URL version — same end state, very different footprint.
// 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.// 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 */ }
}The same pain points, handled by the pipeline — not by whoever is on call this week.
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.
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.
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`.
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.
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.
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.
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.
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.
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.
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.
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.
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.
OpenAPI to TypeScript SDK
Turn any OpenAPI 3.x document into a published, auto-rebuilding TypeScript SDK — without adding a single file to your API repo.
OpenAPI to npm package
Go from an OpenAPI URL to a versioned npm package on your registry — public, private, or custom — every time the schema changes.
OpenAPI to GitHub Packages
Publish a TypeScript SDK from your OpenAPI document to GitHub Packages, re-published automatically every time the schema changes.
OpenAPI to private npm registry
Publish your TypeScript SDK to a private npm registry — Verdaccio, JFrog Artifactory, Sonatype Nexus, AWS CodeArtifact, Cloudsmith — without writing the glue yourself.
One app on the Free tier, no card required. Paste your OpenAPI URL and see the generated Zod-validated client in minutes.