Type‑Safe Bidirectional Validation: Keeping Zod Schemas and OpenAPI/JSON Schema in Sync
Introduction
When building a TypeScript‑backed API, two artifacts often drift apart:
| Artifact | Typical Use |
|---|---|
| OpenAPI / JSON Schema | Contract for external consumers, API docs, client generators |
| Zod schemas | Runtime validation of incoming/outgoing data, type inference for internal code |
If the OpenAPI spec changes but the Zod validators are forgotten (or vice‑versa), you get silent bugs that surface only in production. A bidirectional sync eliminates this gap: the same source of truth generates both the HTTP contract and the TypeScript validation layer, while TypeScript’s type system guarantees compile‑time safety.
In this article we’ll walk through a practical workflow that:
- Derives Zod schemas from an OpenAPI document (or JSON Schema) using code generation.
- Generates an OpenAPI spec from Zod schemas for cases where the API is the source of truth.
- Validates request/response payloads at runtime with Zod, while the inferred TypeScript types keep the rest of the codebase safe.
- Integrates the process into a CI pipeline to catch mismatches before they ship.
The patterns shown work with any Node.js framework—Express, Fastify, or Next.js API routes—but the examples use Fastify for brevity.
1. Why “Bidirectional”?
- Single source of truth – you avoid duplicated definitions.
- Compile‑time guarantees – TypeScript infers types from Zod, so any misuse is caught during development.
- Runtime safety – Zod validates actual payloads, protecting against malformed data from clients or downstream services.
- Documentation & client generation – OpenAPI stays up‑to‑date, enabling Swagger UI, Postman collections, or typed client SDKs.
The “bidirectional” term means you can start from either side (OpenAPI → Zod or Zod → OpenAPI) and keep them in sync automatically.
2. Generating Zod Schemas from OpenAPI
2.1 Tooling
The community provides a solid generator: zod-to-openapi (for the reverse direction) and openapi-zod-client (for forward generation). We’ll use openapi-zod-client because it produces clean, tree‑shakable Zod schemas and also exports the inferred TypeScript types.
npm i -D openapi-zod-client
2.2 Sample OpenAPI fragment
# openapi.yaml
openapi: 3.1.0
info:
title: Todo API
version: 1.0.0
paths:
/todos:
post:
summary: Create a todo
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewTodo'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Todo'
components:
schemas:
NewTodo:
type: object
required: [title]
properties:
title:
type: string
minLength: 1
dueDate:
type: string
format: date-time
Todo:
allOf:
- $ref: '#/components/schemas/NewTodo'
- type: object
required: [id, createdAt]
properties:
id:
type: integer
format: int64
createdAt:
type: string
format: date-time
2.3 Generate the code
Create a script scripts/generate-zod.ts:
import { generateZodClient } from 'openapi-zod-client';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
async function main() {
const output = await generateZodClient({
schemaPath: resolve(__dirname, '../openapi.yaml'),
// Optional: customize naming or export style
clientName: 'TodoApi',
// Set `exportTypes: true` to get both Zod and inferred types
exportTypes: true,
});
writeFileSync(resolve(__dirname, '../src/generated/todo-schema.ts'), output);
}
main().catch(err => {
console.error(err);
process.exit(1);
});
Run it:
npx ts-node scripts/generate-zod.ts
You’ll get a file resembling:
// src/generated/todo-schema.ts
import { z } from 'zod';
export const NewTodoSchema = z.object({
title: z.string().min(1),
dueDate: z.string().datetime().optional(),
});
export type NewTodo = z.infer<typeof NewTodoSchema>;
export const TodoSchema = NewTodoSchema.extend({
id: z.bigint(),
createdAt: z.string().datetime(),
});
export type Todo = z.infer<typeof TodoSchema>;
Now the Zod schemas are directly derived from the OpenAPI contract, guaranteeing they reflect every constraint (required fields, formats, min/max, etc.).
3. Using the Schemas in a Fastify Service
// src/routes/todo.ts
import { FastifyInstance } from 'fastify';
import { NewTodoSchema, TodoSchema } from '../generated/todo-schema';
import { z } from 'zod';
export default async function todoRoutes(app: FastifyInstance) {
app.post('/todos', async (request, reply) => {
// Fastify already parses JSON body, now validate with Zod
const parseResult = NewTodoSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.status(400).send({ errors: parseResult.error.format() });
}
// At this point `parseResult.data` is a fully typed NewTodo
const newTodo = parseResult.data;
// Simulate DB insert
const savedTodo = {
...newTodo,
id: 123n,
createdAt: new Date().toISOString(),
};
// Validate the outgoing shape (helps catch programming errors)
const out = TodoSchema.parse(savedTodo);
return reply.status(201).send(out);
});
}
Key take‑aways:
- Runtime validation protects both inbound and outbound payloads.
z.inferprovides the exact TypeScript type (NewTodo,Todo) used elsewhere (service layers, business logic, tests) without manual duplication.
4. Generating OpenAPI from Zod (Reverse Flow)
Sometimes the codebase drives the contract. You may start with Zod schemas that already encode validation rules and want an OpenAPI document for external consumers.
4.1 Tooling
zod-to-openapi is the go‑to library:
npm i -D zod-to-openapi
4.2 Annotating Zod for richer metadata
OpenAPI supports descriptions, examples, and enumerations that Zod doesn’t expose by default. Use the openapi method provided by the library:
import { z } from 'zod';
import { OpenAPIObject, extendZodWithOpenApi } from 'zod-to-openapi';
extendZodWithOpenApi(z); // adds .openapi() to Zod schemas
export const PrioritySchema = z.enum(['low', 'medium', 'high']).openapi({
description: 'Priority level of the todo',
example: 'medium',
});
export const NewTodoSchema = z.object({
title: z.string().min(1).openapi({
description: 'Human readable title',
example: 'Buy milk',
}),
dueDate: z.string().datetime().optional().openapi({
description: 'ISO date‑time string',
}),
priority: PrioritySchema.optional(),
});
4.3 Building the spec
import { OpenAPIObject } from 'zod-to-openapi';
import { NewTodoSchema, TodoSchema } from './todo-schema';
const openapi: OpenAPIObject = {
openapi: '3.1.0',
info: {
title: 'Todo API',
version: '1.0.0',
},
paths: {
'/todos': {
post: {
summary: 'Create a todo',
requestBody: {
required: true,
content: {
'application/json': {
schema: NewTodoSchema.openapi(),
},
},
},
responses: {
'201': {
description: 'Created',
content: {
'application/json': {
schema: TodoSchema.openapi(),
},
},
},
},
},
},
},
components: {
schemas: {
NewTodo: NewTodoSchema.openapi(),
Todo: TodoSchema.openapi(),
},
},
};
import { writeFileSync } from 'fs';
writeFileSync('openapi-generated.yaml', JSON.stringify(openapi, null, 2));
Running this script produces an OpenAPI document that exactly mirrors the Zod definitions, including constraints like minLength, format, and enum values.
5. Keeping Both Sides in Sync – The CI Guard
Even with generators, a developer could edit the OpenAPI file manually or adjust a Zod schema without re‑running the generator. A quick CI check can prevent drift:
# .github/workflows/schema-sync.yml
name: Schema Sync
on:
push:
paths:
- 'openapi.yaml'
- 'src/generated/**'
- 'scripts/generate-zod.ts'
jobs:
check-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install deps
run: npm ci
- name: Regenerate Zod schemas
run: npx ts-node scripts/generate-zod.ts
- name: Diff generated files
run: |
git diff --exit-code src/generated/todo-schema.ts || (
echo "Generated Zod schemas are out of sync with openapi.yaml"
exit 1
)
If the OpenAPI file changes, the job fails until the developer re‑runs the generation script. The same approach works in the opposite direction (run the OpenAPI generator from Zod and diff the resulting YAML).
6. Advanced Tips
| Problem | Solution |
|---|---|
Circular references (e.g., a User schema referencing Todo and vice‑versa) |
Use z.lazy(() => UserSchema) in Zod; openapi-zod-client resolves $ref automatically. |
| Custom formats (e.g., UUID) | Define a Zod refinement and add an OpenAPI format via .openapi({ format: 'uuid' }). |
Conditional validation (e.g., if status === "completed" then completedAt` required) |
Use Zod's .refine for runtime; for OpenAPI, add a oneOf schema manually or augment the generated spec with a post‑process script. |
| Large monorepos | Keep a single schemas package that other services import. Export both Zod and generated OpenAPI to avoid duplication across packages. |
| Testing | Write a Jest test that imports the generated OpenAPI JSON and validates a sample payload with the Zod schema, ensuring the two truly match. |
7. Full Example Repository Layout
my-todo-service/
├─ src/
│ ├─ generated/
│ │ └─ todo-schema.ts # <-- generated from openapi.yaml
│ ├─ routes/
│ │ └─ todo.ts # Fastify route using the schemas
│ └─ app.ts # Fastify server bootstrap
├─ scripts/
│ ├─ generate-zod.ts # OpenAPI → Zod
│ └─ generate-openapi.ts # Zod → OpenAPI (optional)
├─ openapi.yaml # Source of truth (or generated)
├─ package.json
└─ tsconfig.json
Running npm run build could first invoke both generation scripts, then compile the TypeScript source, guaranteeing the compiled code always works with the latest contract.
8. Conclusion
By treating Zod and OpenAPI/JSON Schema as two faces of the same model, you achieve:
- Zero‑maintenance duplication – change one file, generate the other.
- Compile‑time confidence – TypeScript types derived from Zod are always up‑to‑date.
- Runtime resilience – Zod catches malformed payloads before they corrupt business logic.
- External friendliness – consumers get a standards‑compliant OpenAPI spec for SDK generation and documentation.
Implementing the bidirectional pipeline takes a few minutes of setup, but it pays off quickly in reduced bugs, clearer contracts, and happier teams.
Happy coding!
Member discussion