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
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>;
.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>;
.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();
};
}
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();
};
}
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>;
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);
}));
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>;
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 -
safeParseinstead ofparsegives you structured error handling without try/catch -
validate()middleware overwritesreq.bodywith transformed data — handlers receive clean input -
z.infereliminates 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)