5 min read

Type‑Safe Bidirectional Validation: Keeping Zod Schemas and OpenAPI/JSON Schema in Sync

Learn how to generate, consume, and validate Zod schemas from OpenAPI/JSON Schema so runtime checks and compile‑time types stay perfectly aligned.
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:

  1. Derives Zod schemas from an OpenAPI document (or JSON Schema) using code generation.
  2. Generates an OpenAPI spec from Zod schemas for cases where the API is the source of truth.
  3. Validates request/response payloads at runtime with Zod, while the inferred TypeScript types keep the rest of the codebase safe.
  4. 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.infer provides 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!