js

Build a Type-Safe Express API with Zod and OpenAPI 3.1

Learn how to build a type-safe Express API with Zod and OpenAPI 3.1 for runtime validation, synced docs, and faster frontend integration.

Build a Type-Safe Express API with Zod and OpenAPI 3.1

I have been writing APIs for over a decade. And for most of that time, I have been fighting a losing battle. I update a route handler, forget to touch the Swagger file, and suddenly the frontend team is calling my endpoint with the wrong payload. The application does not crash. It just behaves oddly. Logs fill up. Debugging takes hours. The root cause? A silent drift between what my API claims to do and what it actually does.

I decided to stop chasing my tail. What if my validation schema became the single source of truth for both runtime safety and documentation? What if I could define the shape of my request and response once, and from that single definition, derive TypeScript types, enforce validation, and generate a live OpenAPI 3.1 spec? This tutorial shows you exactly how to build that system using the tools I trust: Express, Zod, and the zod-to-openapi bridge.

Let me walk you through it. By the end, you will have a fully type‑safe, self‑documenting REST API where types, validation, and docs are permanently locked together. Ready? Let us begin.


I start with a fresh project. TypeScript, Express, Zod, and the glue library @asteasolutions/zod-to-openapi. The setup is minimal but deliberate. I want my schemas to carry enough metadata so the generated Swagger UI is useful, not just a technical output.

First, I install the core packages:

npm install express zod @asteasolutions/zod-to-openapi swagger-ui-express
npm install -D typescript ts-node-dev @types/express @types/swagger-ui-express

Then I enable strict mode in tsconfig.json and set up a clean folder structure. Every file has a specific job: schemas, middleware, routes, controllers, and an OpenAPI registry that acts as the central meeting point.


The heart of the system is the OpenAPI registry. I create a singleton that will be imported everywhere my schemas need to be exposed to the OpenAPI spec.

// src/openapi/registry.ts
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
export const registry = new OpenAPIRegistry();

Why a registry? Because zod-to-openapi needs a place to collect your schemas and their OpenAPI metadata before generating the final document. Think of it as a dependency injection container for your API contracts. Every schema that should appear in the generated spec must be registered here with a unique component name.

Now I extend Zod with the .openapi() method. This single call allows me to attach description, example, and other OpenAPI properties directly to my Zod schemas. No separate spec file, no decorator magic.

import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);

I then define shared schemas that will be reused across the whole API. Pagination queries, error responses, UUID parameters. Each is registered with the registry.

export const PaginationQuerySchema = registry.register(
  "PaginationQuery",
  z.object({
    page: z.string().optional().default("1")
      .transform(Number)
      .pipe(z.number().int().min(1))
      .openapi({ description: "Page number (1‑indexed)", example: "1" }),
    limit: z.string().optional().default("10")
      .transform(Number)
      .pipe(z.number().int().min(1).max(100))
      .openapi({ description: "Items per page (max 100)", example: "10" }),
  })
);

Notice the .pipe() chain – I accept a string from query parameters, transform it to a number, and then validate it as an integer. Zod handles the type coercion at runtime, and the .openapi() call ensures the generated spec shows that the parameter is a string (because query parameters are always strings). This is a pattern I use constantly.

Now, what about generic success responses? I want a standard envelope without registering every variant. I create a generic factory function:

export const SuccessResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
  z.object({
    success: z.literal(true),
    data: dataSchema,
    meta: z.object({
      timestamp: z.string().datetime(),
      requestId: z.string().uuid(),
    }).optional(),
  });

This is not registered. Instead, it is composed per‑route inside the route handler. The concrete error response, however, is a fixed shape that appears everywhere, so I register it.


With the shared schemas ready, I move to the user domain. A typical user object might look like this:

export const UserSchema = registry.register(
  "User",
  z.object({
    id: z.string().uuid(),
    email: z.string().email(),
    name: z.string().min(2).max(100),
    createdAt: z.string().datetime(),
  }).openapi({ description: "User resource" })
);

export const CreateUserSchema = registry.register(
  "CreateUser",
  z.object({
    email: z.string().email().openapi({ example: "[email protected]" }),
    name: z.string().min(2).max(100).openapi({ example: "Alice" }),
  })
);

Every schema is typed. z.infer<typeof UserSchema> gives me the TypeScript type automatically. No duplication, no sync issue.


But schemas alone are not enough. I need to enforce them on incoming requests. Here comes the validation middleware. This is where the magic happens: I take a Zod schema and return an Express middleware that validates req.body, req.query, or req.params.

// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";

interface ValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

export const validate = (schemas: ValidationSchemas) => {
  return (req: Request, _res: Response, next: NextFunction) => {
    try {
      if (schemas.body) {
        req.body = schemas.body.parse(req.body);
      }
      if (schemas.query) {
        req.query = schemas.query.parse(req.query) as any;
      }
      if (schemas.params) {
        req.params = schemas.params.parse(req.params) as any;
      }
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        // Format the error for the client
        return _res.status(400).json({
          success: false,
          error: {
            code: "VALIDATION_ERROR",
            message: "Request validation failed",
            details: error.errors.map(e => ({
              path: e.path.join("."),
              message: e.message,
            })),
          },
        });
      }
      next(error);
    }
  };
};

I love this pattern because it both validates and transforms. When zod.parse() succeeds, it returns the coerced and defaulted values. I replace req.body with the parsed result, so my controllers always receive clean, typed data.

Now the route definition. I use Express Router and plug the validation middleware in:

// src/routes/user.routes.ts
import { Router } from "express";
import { validate } from "../middleware/validate";
import { CreateUserSchema, PaginationQuerySchema, UUIDParamSchema } from "../schemas";
import * as userController from "../controllers/user.controller";

const router = Router();

router.get(
  "/",
  validate({ query: PaginationQuerySchema }),
  userController.listUsers
);

router.post(
  "/",
  validate({ body: CreateUserSchema }),
  userController.createUser
);

router.get(
  "/:id",
  validate({ params: UUIDParamSchema }),
  userController.getUser
);

export default router;

The controller does the real work. It receives already‑validated data, so there is no risk of SQL injection or type errors. And because I use z.infer, the TypeScript compiler checks everything.

// src/controllers/user.controller.ts
import { Request, Response } from "express";
import { CreateUserSchema, PaginationQuerySchema } from "../schemas";

export const createUser = async (req: Request, res: Response) => {
  const body = req.body as z.infer<typeof CreateUserSchema>;
  // In real life: save to database
  const newUser = { id: crypto.randomUUID(), ...body, createdAt: new Date().toISOString() };
  res.status(201).json({ success: true, data: newUser });
};

But wait – how does the OpenAPI spec get built and served? I generate it from the registry and wire it to Swagger UI Express.

// src/openapi/generator.ts
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { registry } from "./registry";

export function generateOpenApiDoc() {
  const generator = new OpenApiGeneratorV3(registry.definitions);
  return generator.generateDocument({
    openapi: "3.1.0",
    info: { title: "My Type-Safe API", version: "1.0.0" },
    servers: [{ url: "http://localhost:3000" }],
  });
}

Then in the Express app bootstrap:

// src/app.ts
import express from "express";
import swaggerUi from "swagger-ui-express";
import { generateOpenApiDoc } from "./openapi/generator";
import userRoutes from "./routes/user.routes";

const app = express();
app.use(express.json());

// Generate spec once (or regenerate on schema change in dev)
const spec = generateOpenApiDoc();
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(spec));
// Also expose raw JSON
app.get("/api-docs.json", (_, res) => res.json(spec));

// Mount routes
app.use("/api/users", userRoutes);

// Global error handler
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({
    success: false,
    error: { code: "INTERNAL_ERROR", message: "Something went wrong" },
  });
});

app.listen(3000, () => console.log("Server running on port 3000"));

Now when I visit /api-docs, I see a fully interactive Swagger UI. Every parameter, request body, and response shape is documented exactly as I defined it in Zod. And because the spec is generated from the registry, any change to a schema automatically propagates to the docs when the server restarts.

Have you ever deployed an API only to discover later that the frontend was sending a field you had renamed? That stops today. The frontend team can read the spec directly from /api-docs.json and generate their own clients with OpenAPI generators. No more Slack messages asking, “What’s the shape of the user object?”

I also add a small personal touch: in development, I use ts-node-dev with the --watch flag so the spec regenerates on every file change. I can edit a Zod schema in my editor, refresh the Swagger UI, and see the updated docs. The feedback loop is instant.

But there is one more thing. My validation middleware returns a structured error response that matches the registered ErrorResponse schema. That means the OpenAPI spec for every endpoint automatically includes the correct error schema. I do not have to remember to add a 400 response manually – the generator can be instructed to produce them, or I can use the @asteasolutions/zod-to-openapi’s route registration helpers to link the error component. In a production setting, I prefer to manually register each route in the registry using registerPath() to get full control over HTTP status codes and descriptions. Here is a quick example:

registry.registerPath({
  method: "post",
  path: "/api/users",
  summary: "Create a new user",
  request: {
    body: {
      content: { "application/json": { schema: CreateUserSchema } },
    },
  },
  responses: {
    201: {
      description: "User created successfully",
      content: { "application/json": { schema: SuccessResponseSchema(UserSchema) } },
    },
    400: {
      description: "Validation error",
      content: { "application/json": { schema: ErrorResponseSchema } },
    },
  },
});

Now every endpoint in my Swagger UI shows the exact request and response shapes, including the error cases. The frontend team can generate TypeScript types from the OpenAPI spec using openapi-typescript, and they get compile‑time safety too.

You might ask: “Is this additional boilerplate worth it?” For a simple CRUD endpoint, perhaps not. But for a complex API with dozens of endpoints, a validation layer that simultaneously acts as documentation saves hours of debugging and prevents deployment disasters. I have seen projects where the OpenAPI spec was written months after the code, and it was never accurate. This approach forces a discipline where documentation is a byproduct of the code, not an afterthought.


Now I want you to imagine the following scenario. You join a team mid‑project. You open the codebase, look at the schemas folder, and see a clear list of Zod objects. You open the routes folder and see validation middleware attached to every route. You run the server, navigate to /api-docs, and see a complete, interactive catalog of every endpoint. How long does it take you to understand the API? Ten minutes? What if I told you that this same setup also reduces the number of runtime errors by a significant margin because every input is validated with strict rules?

This is the kind of engineering I aim for – clarity, safety, and minimal surprises.


Before we wrap up, let me share a small tip I learned the hard way. Always validate req.query after applying defaults and transforms. Query parameters are strings in Express, and if your schema expects a number, a missing parameter can either be undefined or NaN. With Zod’s .pipe(), you can safely coerce. But remember that the Swagger UI will display the query parameter type as string (because in OpenAPI 3.1, query params are always string until you set style: form and explode: false). The zod-to-openapi library handles this mapping for basic types, but for complex objects you may need to use .openapi({ schema: { type: "string" } }) explicitly. Check the documentation for edge cases.


I have shown you the pieces: a single registry, schemas that carry OpenAPI metadata, a reusable validation middleware, and a generator that outputs a spec. Put them together, and you get an API that is type‑safe at compile time, validated at runtime, and documented automatically. No more stale Swagger files, no more duplicated type definitions, no more confusion between teams.

If you found this approach useful, I would love to hear your thoughts. Like this article if it saved you time. Share it with a colleague who is still writing OpenAPI by hand. Comment below with your own experiences – have you used a similar pattern? What challenges did you face? Your feedback helps me write better, more practical guides.

Now go ahead, set up your own project, and let your Zod schemas do the talking. Your future self – and your frontend team – will thank you.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Express API, Zod validation, OpenAPI 3.1, TypeScript REST API, zod-to-openapi



Similar Posts
Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Master database operations, migrations, and seamless React development.

Blog Image
How to Build End-to-End Encryption in a Node.js Chat App with Signal Protocol

Learn end-to-end encryption in a Node.js chat app using Signal Protocol, libsodium, X3DH, and double ratchet. Build secure messaging now.

Blog Image
EventStore and Node.js Complete Guide: Event Sourcing Implementation Tutorial with TypeScript

Master event sourcing with EventStore and Node.js: complete guide to implementing aggregates, commands, projections, snapshots, and testing strategies for scalable applications.

Blog Image
How to Build a Distributed Rate Limiter with Redis and Node.js Implementation Guide

Learn to build a scalable distributed rate limiter using Redis and Node.js. Covers Token Bucket, Sliding Window algorithms, Express middleware, and production optimization strategies.

Blog Image
Build Production GraphQL API: NestJS, Prisma & Redis Caching Complete Tutorial

Build a production-ready GraphQL API with NestJS, Prisma & Redis. Learn scalable architecture, caching, auth, and deployment best practices for high-performance APIs.

Blog Image
Build High-Performance Rate Limiting with Redis and Node.js: Complete Developer Guide

Learn to build production-ready rate limiting with Redis and Node.js. Implement token bucket, sliding window algorithms with middleware, monitoring & performance optimization.