DEV Community

Young Gao
Young Gao

Posted on

Request Validation at the Edge: Zod Schemas, OpenAPI, and Type-Safe APIs (2026)

Your TypeScript types vanish at runtime. Every req.body is any wearing a costume.

The Gap Between Types and Reality

TypeScript gives you compile-time safety. But HTTP requests don't come from your compiler — they come from the internet. A POST /users endpoint typed as { name: string; email: string } will happily accept { name: 42, email: null } at runtime unless you validate.

Most teams handle this one of three ways:

  1. Manual if checks scattered through handlers (tedious, incomplete)
  2. Class-validator decorators (heavy, reflection-based)
  3. Nothing (bold strategy)

There's a better path: define your schema once, validate at the edge, generate your OpenAPI spec, and share types across your stack.

Zod: Schema as the Source of Truth

Zod lets you define schemas that are both runtime validators and TypeScript type generators.

import { z } from 'zod';

export const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'member', 'viewer']).default('member'),
  metadata: z.record(z.string()).optional(),
});

export type CreateUserInput = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; role: 'admin' | 'member' | 'viewer'; metadata?: Record<string, string> }
Enter fullscreen mode Exit fullscreen mode

One definition. Runtime validation and static types. No drift.

The real power shows up with composition:

const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

const SortSchema = z.object({
  sortBy: z.string().optional(),
  order: z.enum(['asc', 'desc']).default('desc'),
});

const ListUsersQuerySchema = PaginationSchema.merge(SortSchema).extend({
  search: z.string().optional(),
  role: z.enum(['admin', 'member', 'viewer']).optional(),
});
Enter fullscreen mode Exit fullscreen mode

Query string values are always strings. z.coerce.number() handles the parse-and-validate in one step — no more parseInt sprinkled through your handlers.

Middleware: Validate Before Your Handler Runs

Validation belongs at the edge of your request lifecycle. Your handler should never see invalid data.

import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

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

function validate(schemas: ValidationSchemas) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const errors: { location: string; issues: z.ZodIssue[] }[] = [];

    for (const [location, schema] of Object.entries(schemas)) {
      if (!schema) continue;
      const result = await schema.safeParseAsync(req[location as keyof Request]);
      if (!result.success) {
        errors.push({ location, issues: result.error.issues });
      } else {
        // Replace raw input with parsed + coerced data
        (req as any)[location] = result.data;
      }
    }

    if (errors.length > 0) {
      return res.status(422).json(formatValidationError(errors));
    }

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Key detail: we replace req.body / req.query with the parsed output. This means defaults are applied, coercions happen, and .optional() fields missing from input become undefined — not the string "undefined".

Usage is clean:

router.post(
  '/users',
  validate({ body: CreateUserSchema }),
  async (req, res) => {
    // req.body is validated and typed
    const user = await userService.create(req.body);
    res.status(201).json(user);
  }
);

router.get(
  '/users',
  validate({ query: ListUsersQuerySchema }),
  async (req, res) => {
    // req.query.page is already a number, not a string
    const result = await userService.list(req.query);
    res.json(result);
  }
);
Enter fullscreen mode Exit fullscreen mode

Error Response Formatting

Don't leak Zod internals to your API consumers. Normalize errors into a consistent shape.

interface ValidationErrorResponse {
  error: 'VALIDATION_ERROR';
  message: string;
  details: {
    location: string;
    path: string;
    message: string;
  }[];
}

function formatValidationError(
  errors: { location: string; issues: z.ZodIssue[] }[]
): ValidationErrorResponse {
  const details = errors.flatMap(({ location, issues }) =>
    issues.map((issue) => ({
      location,
      path: issue.path.join('.'),
      message: issue.message,
    }))
  );

  return {
    error: 'VALIDATION_ERROR',
    message: `Invalid ${details.map((d) => d.location).join(', ')}`,
    details,
  };
}
Enter fullscreen mode Exit fullscreen mode

A request with { name: "", email: "not-an-email" } returns:

{
  "error": "VALIDATION_ERROR",
  "message": "Invalid body",
  "details": [
    { "location": "body", "path": "name", "message": "String must contain at least 1 character(s)" },
    { "location": "body", "path": "email", "message": "Invalid email" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Consistent. Parseable. No stack traces.

Generating OpenAPI from Zod

Your schemas already describe your API contract. Generate the spec — don't write it by hand.

import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

const registry = new OpenAPIRegistry();

registry.registerPath({
  method: 'post',
  path: '/users',
  request: {
    body: {
      content: { 'application/json': { schema: CreateUserSchema } },
    },
  },
  responses: {
    201: {
      description: 'User created',
      content: { 'application/json': { schema: UserResponseSchema } },
    },
    422: {
      description: 'Validation error',
      content: { 'application/json': { schema: ValidationErrorSchema } },
    },
  },
});

const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
  openapi: '3.0.3',
  info: { title: 'Users API', version: '1.0.0' },
});

// Write spec to file for CI, Swagger UI, or client generation
fs.writeFileSync('openapi.json', JSON.stringify(spec, null, 2));
Enter fullscreen mode Exit fullscreen mode

Now your docs, your validation, and your types all come from the same source. Change the schema, everything updates.

Shared Schemas: Frontend and Backend

This is where the approach pays compound interest. Extract schemas into a shared package.

packages/
  shared/
    src/
      schemas/
        user.ts      # Zod schemas + inferred types
        pagination.ts
      index.ts
  api/
    src/
      routes/user.ts  # imports from @myapp/shared
  web/
    src/
      api/user.ts     # imports from @myapp/shared
Enter fullscreen mode Exit fullscreen mode

Frontend uses the same schemas for form validation:

// packages/web/src/components/CreateUserForm.tsx
import { CreateUserSchema, type CreateUserInput } from '@myapp/shared';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

function CreateUserForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<CreateUserInput>({
    resolver: zodResolver(CreateUserSchema),
  });

  const onSubmit = async (data: CreateUserInput) => {
    // data is already validated — same rules as the backend
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* form fields */}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Same schema validates the form client-side and the request server-side. A field constraint change propagates to both instantly.

Common Mistakes

Validating inside the handler. If validation is mixed with business logic, you'll forget it on one route. Middleware makes it structural.

Not replacing req.body with parsed output. If you validate but still read the raw input, you miss defaults and coercions. Always assign the parsed result back.

Over-constraining early. Start with loose schemas and tighten. Adding .max(100) is a non-breaking change. Reducing .max(50) to .max(30) is breaking.

Skipping response validation in development. Add a dev-only middleware that validates outgoing responses against your schemas. Catches drift between your actual return values and your documented contract before your consumers do.

Using z.object() for query params without coercion. Query strings are always strings. z.coerce.number() and z.coerce.boolean() exist for this reason. Without them, ?page=2 fails validation because "2" is not a number.

Not versioning your shared package. Monorepo or not, pin versions. A broken schema change in shared shouldn't silently break both your frontend and backend in the same deploy.


Part of my Production Backend Patterns series. Follow for more practical backend engineering.


If this was useful, consider:

Top comments (0)