DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Zod Validation Patterns with Claude Code: Schema Reuse and Type Inference

No validation means invalid data silently enters your database — null where a string was expected, an empty email address, an integer where an enum was required. Claude Code can generate complete Zod validation for every endpoint, but only if you tell it the pattern to follow.


The CLAUDE.md Setup

## Validation

- All endpoints require Zod validation
- Run validation at the start of the handler (before any DB calls)
- Return 422 for validation failures with { errors: [{ field, message }] }
- All string fields need .max() to prevent oversized payloads
- Use .strict() to reject unknown fields
- Export z.infer types alongside every schema
Enter fullscreen mode Exit fullscreen mode

With this in place, Claude Code generates schemas that are consistent, type-safe, and reject unexpected payloads.


User Schema with .strict() and Type Inference

import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(1).max(100).trim(),
  email: z.string().email().max(255).toLowerCase(),
  role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
  age: z.number().int().min(0).max(150).optional(),
}).strict();

export type CreateUserInput = z.infer<typeof createUserSchema>;
Enter fullscreen mode Exit fullscreen mode

.strict() rejects any field not declared in the schema. Without it, { name: 'Alice', injectedField: '<script>...' } passes through silently.

.toLowerCase() on email is a transform — the parsed value is already normalized before it reaches your database.

z.infer<typeof createUserSchema> gives you a TypeScript type from the schema with zero duplication. No separate interface to keep in sync.


Partial Updates with .partial() and .omit()

export const updateUserSchema = createUserSchema
  .partial()
  .omit({ email: true });

export type UpdateUserInput = z.infer<typeof updateUserSchema>;
Enter fullscreen mode Exit fullscreen mode

.partial() makes every field optional — correct for PATCH semantics.

.omit({ email: true }) prevents email changes through the update endpoint. If email changes require a separate verification flow, this enforces that at the schema level.


validate() Middleware

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

export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(422).json({
        errors: result.error.errors.map((e) => ({
          field: e.path.join('.'),
          message: e.message,
        })),
      });
      return;
    }
    req.body = result.data; // overwrite with parsed/transformed data
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

safeParse never throws — it returns { success: true, data } or { success: false, error }. The middleware overwrites req.body with result.data, so handlers receive already-transformed values (trimmed strings, lowercased email, coerced numbers).


validateQuery() for Query Parameters

export function validateQuery(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.query);
    if (!result.success) {
      res.status(422).json({
        errors: result.error.errors.map((e) => ({
          field: e.path.join('.'),
          message: e.message,
        })),
      });
      return;
    }
    req.query = result.data;
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Query parameters arrive as strings. z.coerce.number() handles the conversion — no manual parseInt scattered through handler code.


Pagination Schema with z.coerce

export const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(['asc', 'desc']).default('desc'),
});

export type PaginationQuery = z.infer<typeof paginationSchema>;
Enter fullscreen mode Exit fullscreen mode

z.coerce.number() converts "42" (a string from req.query) to 42 (a number). The .default() values mean omitting the param is not an error.


Route Usage

import { validate, validateQuery } from './middleware/validate';
import { createUserSchema, updateUserSchema, paginationSchema } from './schemas/user';

router.post('/users', validate(createUserSchema), asyncHandler(async (req, res) => {
  const input: CreateUserInput = req.body; // already typed and transformed
  const user = await userService.create(input);
  res.status(201).json(user);
}));

router.patch('/users/:id', validate(updateUserSchema), asyncHandler(async (req, res) => {
  const user = await userService.update(req.params.id, req.body);
  res.json(user);
}));

router.get('/users', validateQuery(paginationSchema), asyncHandler(async (req, res) => {
  const { page, limit, sort } = req.query as PaginationQuery;
  const users = await userService.list({ page, limit, sort });
  res.json(users);
}));
Enter fullscreen mode Exit fullscreen mode

Validation runs before the handler. If the schema fails, the handler never executes.


Nested Schema: Order with Line Items

const orderItemSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(1).max(999),
  unitPrice: z.number().positive().max(999999),
}).strict();

export const createOrderSchema = z.object({
  customerId: z.string().uuid(),
  items: z.array(orderItemSchema).min(1).max(50),
  postalCode: z.string().regex(/^\d{3}-\d{4}$/, 'Invalid postal code format'),
  notes: z.string().max(500).optional(),
}).strict();

export type CreateOrderInput = z.infer<typeof createOrderSchema>;
Enter fullscreen mode Exit fullscreen mode

Zod validates nested objects recursively. The error path for a nested failure looks like items.0.quantity — which the middleware maps directly to { field: 'items.0.quantity', message: '...' } using e.path.join('.').


Summary

  • Define the validation contract in CLAUDE.md — Claude Code applies it to every new endpoint
  • safeParse instead of parse gives you structured error handling without try/catch
  • validate() middleware overwrites req.body with transformed data — handlers receive clean input
  • z.infer eliminates duplicate TypeScript interface definitions
  • .strict() rejects unexpected fields at the schema level — not something you have to remember per-handler

Want Claude Code to audit your API for missing validation, injection points, and oversized payload vulnerabilities?

Security Pack (¥1,480) /security-check — includes prompts for input validation coverage, schema strictness audit, and payload size limit checks.

Top comments (0)