The Problem: Claude Code Without Validation Rules
Without constraints, Claude Code generates code with common validation mistakes:
- Passing
req.bodyfields directly to a database without type checks - Using scattered manual checks like
typeof req.body.name === 'string' - No validation at all on query parameters or nested objects
Specifying Zod as the standard in CLAUDE.md fixes this at the source.
Define Validation Rules in CLAUDE.md
## Validation Policy
- Use Zod for all input validation (no manual if-checks like typeof/instanceof)
- Place Zod schemas in `src/schemas/` — keep them separate from route handlers
- Use `z.safeParse()` instead of `z.parse()` to handle errors gracefully
- Use `.strict()` to reject unknown fields
- Return HTTP 422 Unprocessable Entity for validation errors
- Error body format: `{ errors: [{ field: string, message: string }] }`
Once this is in CLAUDE.md, Claude Code will generate Zod schemas automatically every time it creates a new API endpoint.
Generating Zod Schemas with Claude Code
Prompt
Add a POST /api/products endpoint.
Create a Zod schema in src/schemas/product.schema.ts and apply it
through a validation middleware in the route handler.
Generated Code
src/schemas/product.schema.ts
import { z } from "zod";
export const CreateProductSchema = z
.object({
name: z.string().trim().min(1, "Name is required").max(100),
price: z.number().int().positive("Price must be a positive integer"),
category: z.enum(["electronics", "clothing", "food"], {
errorMap: () => ({ message: "Invalid category" }),
}),
description: z.string().trim().max(500).optional(),
tags: z.array(z.string().trim().min(1)).max(10).default([]),
})
.strict(); // reject unknown fields
export type CreateProductInput = z.infer<typeof CreateProductSchema>;
src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
errors: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
req.body = result.data; // replace with parsed, type-safe data
next();
};
}
src/routes/products.ts
import { Router } from "express";
import { validate } from "../middleware/validate";
import { CreateProductSchema } from "../schemas/product.schema";
const router = Router();
router.post("/", validate(CreateProductSchema), async (req, res) => {
// req.body is fully typed as CreateProductInput here
const { name, price, category } = req.body;
res.status(201).json({ message: "Created" });
});
Unified Error Response Format
Prompt to enforce consistent errors
Return all validation errors as HTTP 422.
Format: { errors: [{ field: string, message: string }] }
Never expose raw Zod Issue objects in the response.
Example Response
HTTP/1.1 422 Unprocessable Entity
{
"errors": [
{ "field": "name", "message": "Name is required" },
{ "field": "price", "message": "Price must be a positive integer" }
]
}
The client can map errors by field name and display them next to the corresponding form input.
Validating Query Parameters
Query parameters arrive as strings, so use z.coerce to convert them safely:
export const ListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().optional(),
});
router.get("/", (req, res) => {
const result = ListQuerySchema.safeParse(req.query);
if (!result.success) {
return res.status(422).json({ errors: result.error.issues });
}
const { page, limit, search } = result.data;
// ...
});
Nested Object Validation
export const CreateOrderSchema = z.object({
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(99),
})
)
.min(1, "At least one item is required"),
shippingAddress: z.object({
postalCode: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid postal code format"),
city: z.string().min(1),
street: z.string().min(1),
}),
});
Catch Validation Gaps with Hooks
Use a Claude Code hook to warn when a route handler is written without a validation middleware call:
.claude/hooks/pre-tool-call.sh
#!/bin/bash
TOOL_NAME="$1"
FILE_PATH="$2"
if [[ "$TOOL_NAME" == "write_file" && "$FILE_PATH" == *"routes/"* ]]; then
CONTENT=$(cat)
if echo "$CONTENT" | grep -qE "router\.(post|put|patch)" && \
! echo "$CONTENT" | grep -q "validate("; then
echo "WARNING: Route is missing a validation middleware call." >&2
echo "Apply validate() with a Zod schema before proceeding." >&2
fi
fi
This runs before Claude Code writes the file, giving you a chance to catch missing validation before it lands in the codebase.
Summary
| Practice | Benefit |
|---|---|
| Zod standard in CLAUDE.md | Schemas generated automatically |
safeParse + 422 response |
Consistent, safe error handling |
src/schemas/ directory |
Reusable, centralized schemas |
| Hook for validation gaps | Catches missing validation early |
Validation is unglamorous but it is where most API security issues begin. Telling Claude Code the rules upfront means you stop reviewing for it manually.
Code Review Pack (¥980) includes /code-review for automated validation gap detection. 👉 https://prompt-works.jp
Top comments (0)