Claude Code with Zod: Schemas, Validation, Error Handling
Why Zod needs a project-specific CLAUDE.md
Zod sits at every boundary in a modern TypeScript app. It validates the env at boot, parses request bodies in API handlers, checks form input before submit, validates third-party API responses, and shapes data coming out of databases. Each of those boundaries has different rules about what counts as valid, and each has a different cost when bad data slips through.
Claude Code knows Zod. It can write z.object, z.union, z.string().email(), and most of the standard combinators from memory. What it does not know without help is which boundary you are validating, whether parse or safeParse is the right choice, when coercion is safe and when it is dangerous, and how strict your project wants schemas to be.
Without a project-specific CLAUDE.md, Claude generates Zod schemas that look right but leak. A z.object without .strict() accepts unknown fields. A safeParse result gets unwrapped with .data! without checking .success. A coercion of user input silently turns the string "abc" into NaN instead of failing. An optional() field accepts undefined but rejects null from a JSON payload that uses null for missing values. The schema compiles, the tests pass, and bad data gets through to the database.
This guide covers the CLAUDE.md rules and schema patterns that make Claude Code reliable for Zod. If you are new to Claude Code, the Claude Code setup guide covers installation and authentication first. For broader TypeScript conventions, the Claude Code TypeScript guide covers tsconfig strictness and type generation patterns that pair with Zod.
The Zod CLAUDE.md template
The CLAUDE.md at your project root is read before every Claude Code session. For Zod, it needs to answer: which version, which schema style, where schemas live, when to use parse versus safeParse, and which patterns are forbidden.
# Zod project rules
## Stack
- Zod: 3.23.x (Zod 4 is opt-in, do not migrate without explicit instruction)
- TypeScript: 5.6.x with strict mode and noUncheckedIndexedAccess
- Runtime: Node 20.x for server, modern browsers for client
- Form library: react-hook-form 7.x with @hookform/resolvers/zod
- HTTP: native fetch with Zod-validated responses
- Env loader: dotenv on Node, import.meta.env on Vite
## Project structure
- src/schemas/: shared Zod schemas, one file per domain (user.ts, order.ts, etc.)
- src/schemas/index.ts: re-exports the public schemas
- src/env.ts: env validation, runs at module load, fails the process on error
- src/lib/api.ts: fetch wrappers that take a Zod schema and return parsed data
- API handlers and form components import schemas, never redefine them
## Schema rules
- Object schemas use .strict() unless the boundary explicitly allows unknown fields
- Always export both the schema and the inferred type: export const User = ...; export type User = z.infer<typeof User>
- One source of truth per shape: do not duplicate schemas across client and server
- Use z.discriminatedUnion for tagged unions, never z.union with overlapping shapes
- Use branded types (z.string().brand<"UserId">()) for IDs that must not be mixed
## parse vs safeParse
- Use safeParse at every external boundary: HTTP request, form submit, env, third-party response
- Use parse only inside trusted internal code where a thrown error is the right behaviour
- NEVER call .data without first checking .success, and NEVER use .data! to bypass the check
- safeParse failures must be surfaced with a typed error response, not swallowed
## Coercion rules
- z.coerce.* is forbidden on raw user input from forms or query strings
- For numeric query params, validate as string then transform with a typed parser
- For booleans from query strings, use z.enum(["true", "false"]).transform(v => v === "true")
- z.coerce.date is acceptable on ISO strings from APIs you control, never on freeform input
## Optional vs nullable
- optional() means the key may be absent: { name?: string }
- nullable() means the key is present with value null: { name: string | null }
- For JSON APIs that send explicit null, use .nullable() or .nullish() on the schema
- For form fields where empty means undefined, use .optional() and clean before parse
## Hard rules
- NEVER unwrap safeParse with .data! or as Type
- NEVER use z.any() or z.unknown() in a schema that crosses a trust boundary
- NEVER use z.coerce on values that came directly from req.query, req.body, or form inputs
- NEVER catch and rethrow ZodError as a generic Error; preserve the typed error
- Schema files are pure: no side effects at module top level except in src/env.ts
Three rules in this template prevent the most common Claude Code failures.
The safeParse rule catches the single largest source of runtime errors in TypeScript apps using Zod. parse throws on failure, which is fine inside trusted code but disastrous in a request handler that crashes the worker process for one bad request. safeParse returns { success: true, data } or { success: false, error }, and the type system forces you to check success before accessing data. Without the explicit rule, Claude defaults to parse for shorter code, and exception handling becomes an afterthought.
The coercion rule prevents silent data corruption. z.coerce.number() calls Number(value) under the hood. Number("") is 0. Number("abc") is NaN. Number(null) is 0. None of those throw. A schema that uses z.coerce.number() on a query string accepts garbage and produces zero, which then writes to your database as if it were a real value.
The optional versus nullable rule prevents schema mismatches between what the client sends and what the server validates. JSON APIs typically send null for missing values. Form libraries typically use undefined. A schema that uses .optional() rejects null and a schema that uses .nullable() rejects undefined. Without explicit guidance, Claude picks one based on the surrounding code style and gets it wrong about half the time.
For broader CLAUDE.md structure, CLAUDE.md explained covers how the file is read across project types.
Schema patterns that work
Once CLAUDE.md is in place, give Claude a real schema file to extend rather than generate from scratch. A reference file lets Claude pattern-match against your conventions instead of generic Zod tutorials.
// src/schemas/user.ts
import { z } from 'zod'
// Branded types for IDs that should never be mixed
export const UserId = z.string().uuid().brand<'UserId'>()
export type UserId = z.infer<typeof UserId>
export const OrgId = z.string().uuid().brand<'OrgId'>()
export type OrgId = z.infer<typeof OrgId>
// Base object schema with strict mode
export const User = z
.object({
id: UserId,
orgId: OrgId,
email: z.string().email().max(254),
name: z.string().min(1).max(120),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.coerce.date(),
deletedAt: z.coerce.date().nullable(),
metadata: z.record(z.string(), z.unknown()).default({}),
})
.strict()
export type User = z.infer<typeof User>
// Input schema for creation: omit server-generated fields
export const CreateUserInput = User.pick({
email: true,
name: true,
role: true,
}).extend({
orgId: OrgId,
})
export type CreateUserInput = z.infer<typeof CreateUserInput>
// Patch schema for updates: all fields optional
export const UpdateUserInput = CreateUserInput.partial()
export type UpdateUserInput = z.infer<typeof UpdateUserInput>
A few elements are worth highlighting because Claude reproduces them once they are in your repo.
The branded types on UserId and OrgId are the single highest-leverage pattern for catching ID mix-ups at compile time. Without branding, both are string, and the type system happily lets you pass an OrgId where a UserId is expected. With branding, the types are nominally distinct. The runtime behaviour is unchanged, but every place an ID flows through your code becomes type-checked against confusion.
The .strict() call on the object schema rejects unknown fields rather than silently dropping them. This is the right default for boundaries where the schema is the contract: API request bodies, config files, validated form input. The alternative, .passthrough(), keeps unknown fields in the parsed output, which is occasionally useful for forwarding data through middleware. The default behaviour, .strip(), silently removes unknown fields, which hides bugs.
The User.pick and .partial helpers derive related schemas from the base. This keeps the source of truth in one place. When the User shape changes, CreateUserInput and UpdateUserInput update automatically. Claude Code regularly tries to redefine these from scratch when generating handlers; the rule "never duplicate schemas" in CLAUDE.md prevents that.
Discriminated unions handle polymorphic shapes. They are the right pattern for events, messages, and any data where a field tag determines the rest of the shape:
// src/schemas/event.ts
import { z } from 'zod'
const UserCreatedEvent = z.object({
type: z.literal('user.created'),
userId: z.string().uuid(),
email: z.string().email(),
at: z.coerce.date(),
})
const UserDeletedEvent = z.object({
type: z.literal('user.deleted'),
userId: z.string().uuid(),
at: z.coerce.date(),
})
const OrgUpdatedEvent = z.object({
type: z.literal('org.updated'),
orgId: z.string().uuid(),
changes: z.record(z.string(), z.unknown()),
at: z.coerce.date(),
})
export const Event = z.discriminatedUnion('type', [
UserCreatedEvent,
UserDeletedEvent,
OrgUpdatedEvent,
])
export type Event = z.infer<typeof Event>
The z.discriminatedUnion constructor produces a faster validator and clearer errors than z.union, because it knows the discriminator field upfront and only runs the matching branch. With plain z.union, Zod tries each option in turn and reports a wall of errors when none match. Without explicit guidance, Claude reaches for z.union reflexively, even when a discriminator is present.
For database integrations specifically, the Claude Code with Prisma guide covers patterns for keeping Zod schemas aligned with database models.
parse vs safeParse and error formatting
Choosing between parse and safeParse is the most consequential Zod decision in any codebase. The wrong choice produces either crashes on bad input or silent failures that swallow the validation result.
The rule is simple: every external boundary uses safeParse. Internal trusted code can use parse if the right response to invalid data is to throw.
// src/lib/api.ts
import { z } from 'zod'
type ParsedResponse<T> =
| { ok: true; data: T }
| { ok: false; status: number; error: string; issues?: z.ZodIssue[] }
export async function fetchJson<T>(
url: string,
schema: z.ZodType<T>,
init?: RequestInit,
): Promise<ParsedResponse<T>> {
const res = await fetch(url, init)
if (!res.ok) {
return { ok: false, status: res.status, error: res.statusText }
}
const json = await res.json().catch(() => null)
if (json === null) {
return { ok: false, status: res.status, error: 'invalid_json' }
}
const parsed = schema.safeParse(json)
if (!parsed.success) {
return {
ok: false,
status: res.status,
error: 'schema_mismatch',
issues: parsed.error.issues,
}
}
return { ok: true, data: parsed.data }
}
A few patterns worth noting. The function returns a discriminated union ({ ok: true, data } or { ok: false, ... }) rather than throwing. Callers must check ok before accessing data, mirroring the shape of safeParse itself. The schema mismatch case preserves parsed.error.issues, the structured list of validation problems, so the caller can surface them in a UI or log them with context.
Error formatting is where Claude Code most often produces underwhelming output. The default ZodError looks like this when you stringify it:
[
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": ["email"],
"message": "Expected string, received number"
}
]
Useful for developers, useless for end users. For form errors, you want a flat object keyed by field name. For API responses, you want a stable shape that frontends can render. The standard pattern:
// src/lib/zod-errors.ts
import { z } from 'zod'
export type FieldErrors = Record<string, string[]>
export function fieldErrors(error: z.ZodError): FieldErrors {
const out: FieldErrors = {}
for (const issue of error.issues) {
const path = issue.path.join('.')
if (!out[path]) out[path] = []
out[path].push(issue.message)
}
return out
}
export function firstFieldError(error: z.ZodError): string | null {
const issue = error.issues[0]
return issue ? issue.message : null
}
Add a section to CLAUDE.md telling Claude to use these helpers rather than reinventing error formatting in every handler:
## Error formatting
- API handlers return parsed.error.issues directly under an "issues" key
- Form components use fieldErrors() from src/lib/zod-errors.ts to map errors by field
- Logs include the path and message for the first issue, never the entire ZodError stringified
- Never expose ZodError raw to end users; always go through one of these formatters
For testing patterns around Zod schemas, the Claude Code testing guide covers how to write unit tests for schemas and integration tests for handlers.
Refinements, transforms, and branded types
Zod's basic types get you most of the way. Refinements and transforms cover the cases where the types are not enough on their own.
A refinement adds a custom validation rule that does not change the inferred type. Use refinements when something is valid only relative to other fields, or when a value passes the type check but fails a domain rule:
// src/schemas/booking.ts
import { z } from 'zod'
export const Booking = z
.object({
startAt: z.coerce.date(),
endAt: z.coerce.date(),
guestCount: z.number().int().positive(),
venueCapacity: z.number().int().positive(),
})
.refine((b) => b.endAt > b.startAt, {
message: 'endAt must be after startAt',
path: ['endAt'],
})
.refine((b) => b.guestCount <= b.venueCapacity, {
message: 'guest count exceeds venue capacity',
path: ['guestCount'],
})
.strict()
export type Booking = z.infer<typeof Booking>
The path argument is critical for form validation. Without it, the refinement error attaches to the root of the object, and form libraries like react-hook-form cannot map it to the field that caused the failure. With path: ['endAt'], the error appears under errors.endAt and the form renders it next to the right input.
Transforms change the parsed output. Use transforms to normalise data after validation, never to coerce before validation:
// src/schemas/contact.ts
import { z } from 'zod'
export const Contact = z
.object({
email: z
.string()
.email()
.transform((s) => s.toLowerCase().trim()),
phone: z
.string()
.regex(/^\+\d{10,15}$/)
.transform((s) => s.replace(/\s+/g, '')),
name: z.string().min(1).max(120).trim(),
})
.strict()
export type Contact = z.infer<typeof Contact>
The validation runs on the input, the transform runs on valid output. This means you cannot use a transform to fix invalid input. If email does not pass .email(), the schema fails before the transform runs. The right way to read it: the input shape is what you accept, the output shape is what downstream code consumes.
Branded types deserve a dedicated section because they are the most underused Zod feature and the highest-leverage one for catching bugs at compile time. The earlier User schema used UserId and OrgId brands. The pattern generalises to any string or number that has a logical type beyond its primitive shape:
// src/schemas/branded.ts
import { z } from 'zod'
export const UserId = z.string().uuid().brand<'UserId'>()
export type UserId = z.infer<typeof UserId>
export const OrgId = z.string().uuid().brand<'OrgId'>()
export type OrgId = z.infer<typeof OrgId>
export const Email = z.string().email().brand<'Email'>()
export type Email = z.infer<typeof Email>
export const Slug = z
.string()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
.brand<'Slug'>()
export type Slug = z.infer<typeof Slug>
export const PriceInCents = z.number().int().nonnegative().brand<'PriceInCents'>()
export type PriceInCents = z.infer<typeof PriceInCents>
A function that takes (userId: UserId) rejects a plain string at compile time. You must run a string through UserId.parse(value) to get the branded type, which both validates the format and tags the type. This catches the entire class of bugs where IDs from different domains get mixed up, or where a slug accidentally gets passed to a function expecting an email.
Add a branded types section to CLAUDE.md to nudge Claude toward this pattern by default for IDs:
## Branded types
- All UUID IDs use a brand: UserId, OrgId, ProjectId, etc.
- All email-typed strings use the Email brand, not raw string
- Slugs, semver versions, and other formatted strings use brands
- Money values use PriceInCents (int, non-negative) instead of float
- Branded types live in src/schemas/branded.ts and are imported, never redefined
For React projects specifically, the Claude Code with React guide covers component prop typing patterns that pair well with branded Zod types.
Real-world: env validation and form validation
Two boundaries dominate Zod usage in practice: environment variables at process boot, and form input from users. Both have specific failure modes that warrant explicit patterns.
Env validation runs once at module load, fails the process loudly on error, and produces a typed object that the rest of the codebase imports:
// src/env.ts
import { z } from 'zod'
const EnvSchema = z
.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
PORT: z
.string()
.regex(/^\d+$/)
.transform(Number)
.pipe(z.number().int().min(1).max(65535))
.default('3000'),
})
.strict()
const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:')
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
}
process.exit(1)
}
export const env = parsed.data
Three things to call out. First, safeParse followed by an explicit process.exit(1) produces a clean failure with a readable error list, instead of a thrown ZodError mixed in with the rest of the boot stack trace. Second, PORT is parsed as a string then piped through z.number(), which is the safe alternative to z.coerce.number() because the regex catches non-numeric input before transformation. Third, the schema is .strict(), which means an unexpected env var fails loudly. This catches typos like DATABSE_URL.
For broader env management, the Claude Code environment variables guide covers .env file conventions and secret handling that pair with this validation pattern.
Form validation with react-hook-form follows a different pattern. The schema lives next to the form, the resolver wires it into the form library, and the error formatter maps Zod issues to field-level errors:
// src/components/SignupForm.tsx
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const SignupForm = z
.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(12, 'At least 12 characters'),
confirmPassword: z.string(),
accepted: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
})
.refine((d) => d.password === d.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
})
type SignupForm = z.infer<typeof SignupForm>
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
resolver: zodResolver(SignupForm),
mode: 'onBlur',
})
async function onSubmit(values: SignupForm) {
// values is already validated and typed
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(values),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<input {...register('confirmPassword')} type="password" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<input {...register('accepted')} type="checkbox" />
{errors.accepted && <span>{errors.accepted.message}</span>}
<button type="submit" disabled={isSubmitting}>
Sign up
</button>
</form>
)
}
The zodResolver wires Zod into react-hook-form's validation lifecycle. Field-level errors appear automatically in errors[fieldName]. Cross-field refinements like the password match get the right path so they appear under the correct input. The mode: 'onBlur' setting validates each field when it loses focus, which produces a smoother experience than validating only on submit.
The same SignupForm schema also runs server-side in the API handler. This is the single-source-of-truth principle: define the schema once in src/schemas/, import it into the form and the handler, and bad input is rejected at both ends with identical rules. Without this, the client-side schema and the server-side schema drift apart over time, and validation that passes the form fails at the API or vice versa.
For Next.js-specific patterns, the Claude Code with Next.js guide covers how server actions and route handlers integrate with Zod schemas.
Common pitfalls and debugging
Five Zod pitfalls account for most of the bugs in projects using Claude Code without explicit rules. Each has a fix that belongs in CLAUDE.md.
The z.coerce trap is the most insidious. z.coerce.number() produces NaN for non-numeric input, and NaN passes most downstream type checks. z.coerce.boolean() is even worse: Boolean("false") is true, Boolean(0) is false, Boolean("") is false. Any string except the empty string and "0" becomes true. Using z.coerce.boolean() on a query parameter named ?archived=false produces true, the opposite of what the URL says.
The fix is to validate as a string first, then transform:
// Wrong
const Query = z.object({
page: z.coerce.number().default(1),
archived: z.coerce.boolean().default(false),
})
// Right
const Query = z.object({
page: z
.string()
.regex(/^\d+$/)
.transform(Number)
.default('1'),
archived: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.default('false'),
})
The safeParse unwrap trap comes from forgetting that .data is only present when .success is true. TypeScript narrows correctly when you check success, but Claude sometimes generates code that uses .data! to bypass the check. This produces runtime crashes on invalid input.
The optional vs nullable trap appears when migrating between data sources. A schema with email: z.string().optional() accepts { email: "x@y.z" } and {} but rejects { email: null }. A schema with email: z.string().nullable() accepts the first and third but rejects the second. Use .nullish() (the union of both) when integrating with sources that mix conventions.
The strict mode default trap is silent. By default, z.object strips unknown fields without warning. A typo in a field name produces a parsed object that quietly drops the typo'd field, which then cascades into bugs that look unrelated to validation. Always use .strict() at trust boundaries.
The z.any() and z.unknown() trap is a special case of "any in TypeScript is a security hole." A schema that uses z.any() for a field accepts anything, including injection payloads, prototype pollution attempts, and malformed data that crashes downstream code. Replace z.any() with a specific shape, even if the shape is "this is a string of unknown content" via z.string().
When debugging schema issues, two patterns help. First, use z.ZodError.format() to get a tree-shaped error that maps directly to the input structure. This is often more useful than error.issues for nested objects. Second, use schema.parse(value) in a REPL or console.log during development to surface errors loudly, then switch to safeParse in committed code.
For broader debugging strategies, the Claude Code debugging guide covers tooling and inspection patterns for TypeScript projects.
Hard rules summary
The patterns above reduce to a list of mandatory rules that belong at the top of every Zod CLAUDE.md.
- Every external boundary uses
safeParse.parseis for trusted internal code only. - Never unwrap
safeParseresult without checking.successfirst. No.data!and noas Typecasts. z.coerceis forbidden on raw user input. Validate as a string, then transform.- Object schemas at trust boundaries use
.strict(). Strip is a footgun. - Branded types for all IDs, formatted strings (slugs, emails), and money values.
- Discriminated unions for tagged shapes.
z.unionis a fallback, not a default. - One source of truth per schema. Define once in
src/schemas/, import everywhere. - Optional means absent, nullable means null-valued. Match the wire format.
- Refinements that depend on multiple fields must specify a
pathfor form integration. z.any()andz.unknown()are forbidden at trust boundaries.- Env validation runs at module load, fails the process on error, and exports a typed object.
- Schema files are pure. No side effects at module top level except in
src/env.ts.
These rules prevent the most common Zod failures in Claude Code sessions. They produce schemas that catch bad data at the boundary, error messages that map cleanly to fields, types that prevent ID confusion at compile time, and a single source of truth that keeps client and server in sync.
The TypeScript ecosystem keeps adding boundaries: server actions, route handlers, edge functions, websocket frames. Each one is a place where bad data can enter your system, and each one is a place where Zod earns its keep. With the rules above in CLAUDE.md, Claude Code generates validation that matches your project conventions on the first pass, instead of producing schemas that compile but leak. Claudify includes a Zod-specific CLAUDE.md template pre-configured for the patterns above. For the broader role of CLAUDE.md across project types, the Claude Code best practices guide covers how to structure project rules for any stack.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify