Zod advanced patterns unlock runtime validation at every boundary — z.discriminatedUnion("type", [...]) validates tagged unions efficiently. z.transform modifies parsed values: z.string().transform(s => s.trim().toLowerCase()). z.preprocess((v) => Number(v), z.number()) coerces before parsing. z.superRefine(async (val, ctx) => { const exists = await checkEmail(val); if (!exists) ctx.addIssue({...}) }) runs async cross-field validation. z.lazy(() => NodeSchema) handles recursive types like trees. z.string().brand<"UserId">() creates opaque branded types. z.infer<typeof Schema> extracts the TypeScript type. z.union, z.intersection, z.tuple, z.record, z.map, z.set handle complex structures. Custom error maps: z.setErrorMap((issue, ctx) => ({ message: ... })). .describe("Field description") adds metadata for OpenAPI docs. schema.merge(other) combines object schemas. Server-side: schema.safeParseAsync(data) for async validation. Claude Code generates Zod schemas for API validation, env config, forms, and discriminated event types.
CLAUDE.md for Zod Advanced
## Zod Advanced Stack
- Version: zod >= 3.23
- Transform: z.string().trim().toLowerCase() — pipeline of transforms
- Coerce: z.coerce.number() or z.preprocess(Number, z.number()) for strings to numbers
- Discriminated: z.discriminatedUnion("type", [z.object({ type: z.literal("email"), email: z.string().email() }), ...])
- Async refine: schema.superRefine(async (val, ctx) => { if (await emailTaken(val.email)) ctx.addIssue({...}) })
- Brand: const UserId = z.string().uuid().brand<"UserId">(); type UserId = z.infer<typeof UserId>
- Recursive: const Tree: z.ZodType<TreeNode> = z.lazy(() => z.object({ value: z.string(), children: z.array(Tree) }))
- Env: const env = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().default(3000) }).parse(process.env)
Core Schema Patterns
// lib/validation/schemas.ts — advanced Zod schema patterns
import { z } from "zod"
// ── Branded types ──────────────────────────────────────────────────────────
// Opaque nominal types — UserId is not assignable to PostId even though both are strings
const UserId = z.string().uuid().brand<"UserId">()
const PostId = z.string().uuid().brand<"PostId">()
const TeamId = z.string().uuid().brand<"TeamId">()
export type UserId = z.infer<typeof UserId>
export type PostId = z.infer<typeof PostId>
export type TeamId = z.infer<typeof TeamId>
// Parse and brand
export function parseUserId(id: unknown): UserId {
return UserId.parse(id)
}
// ── Discriminated unions ───────────────────────────────────────────────────
// Efficient union matching — Zod checks the discriminant first
const NotificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
email: z.string().email(),
subject: z.string(),
body: z.string(),
}),
z.object({
type: z.literal("sms"),
phone: z.string().regex(/^\+?[1-9]\d{7,14}$/),
text: z.string().max(160),
}),
z.object({
type: z.literal("push"),
deviceToken: z.string().min(1),
title: z.string(),
body: z.string(),
data: z.record(z.string()).optional(),
}),
z.object({
type: z.literal("in_app"),
userId: UserId,
message: z.string(),
url: z.string().url().optional(),
}),
])
export type Notification = z.infer<typeof NotificationSchema>
// Webhook event union
const WebhookEventSchema = z.discriminatedUnion("event", [
z.object({
event: z.literal("user.created"),
data: z.object({ userId: UserId, email: z.string().email() }),
}),
z.object({
event: z.literal("subscription.activated"),
data: z.object({ userId: UserId, plan: z.enum(["pro", "enterprise"]) }),
}),
z.object({
event: z.literal("payment.failed"),
data: z.object({
userId: UserId,
amount: z.number().positive(),
reason: z.string(),
}),
}),
])
export type WebhookEvent = z.infer<typeof WebhookEventSchema>
// ── Transform pipelines ────────────────────────────────────────────────────
// Sanitize and normalize user input
const SlugSchema = z
.string()
.trim()
.toLowerCase()
.transform((s) =>
s
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, ""),
)
.pipe(z.string().min(3).max(100))
// Money: accept string or number, normalize to cents integer
const MoneySchema = z.preprocess(
(v) => {
if (typeof v === "string") return Math.round(parseFloat(v.replace(/[$,]/g, "")) * 100)
if (typeof v === "number") return Math.round(v * 100)
return v
},
z.number().int().nonnegative(),
)
// Date that accepts ISO string, Date object, or timestamp number
const FlexDateSchema = z.preprocess(
(v) => {
if (v instanceof Date) return v
if (typeof v === "string" || typeof v === "number") return new Date(v)
return v
},
z.date(),
)
// ── Cross-field validation with superRefine ────────────────────────────────
const PasswordChangeSchema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
confirmPassword: z.string(),
})
.superRefine((val, ctx) => {
if (val.newPassword !== val.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
})
}
if (val.newPassword === val.currentPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "New password must differ from current password",
path: ["newPassword"],
})
}
})
// Async superRefine — check uniqueness before creating
const CreateUserSchema = z
.object({
email: z.string().email(),
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-z0-9_-]+$/, "Username can only contain lowercase letters, numbers, underscores, and dashes"),
password: z.string().min(8),
})
.superRefine(async (val, ctx) => {
// Async uniqueness check
const emailTaken = await checkEmailExists(val.email)
if (emailTaken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email address is already in use",
path: ["email"],
})
}
const usernameTaken = await checkUsernameExists(val.username)
if (usernameTaken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Username is already taken",
path: ["username"],
})
}
})
async function checkEmailExists(_email: string): Promise<boolean> {
// DB lookup — implementation omitted
return false
}
async function checkUsernameExists(_username: string): Promise<boolean> {
return false
}
// ── Recursive schema ───────────────────────────────────────────────────────
type TreeNode = {
id: string
name: string
children: TreeNode[]
}
// z.lazy defers schema creation for circular references
const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
z.object({
id: z.string().uuid(),
name: z.string().min(1),
children: z.array(TreeNodeSchema),
}),
)
type CommentWithReplies = {
id: string
content: string
authorId: UserId
createdAt: Date
replies: CommentWithReplies[]
}
const CommentSchema: z.ZodType<CommentWithReplies> = z.lazy(() =>
z.object({
id: z.string().uuid(),
content: z.string().min(1).max(5000),
authorId: UserId,
createdAt: z.coerce.date(),
replies: z.array(CommentSchema),
}),
)
export { NotificationSchema, WebhookEventSchema, CreateUserSchema, PasswordChangeSchema, CommentSchema }
Environment Variable Validation
// lib/env.ts — type-safe environment config with Zod
import { z } from "zod"
const envSchema = z.object({
// Server
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
// Database
DATABASE_URL: z.string().url(),
DATABASE_MAX_CONNECTIONS: z.coerce.number().int().min(1).max(100).default(10),
// Auth
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
JWT_REFRESH_SECRET: z.string().min(32).optional(),
SESSION_SECRET: z.string().min(32),
// External APIs
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
RESEND_API_KEY: z.string().startsWith("re_"),
// Storage
S3_BUCKET: z.string().min(1),
S3_REGION: z.string().default("us-east-1"),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
CLOUDFLARE_R2_ENDPOINT: z.string().url().optional(),
// Feature flags
ENABLE_ANALYTICS: z.coerce.boolean().default(false),
ENABLE_EMAILS: z.coerce.boolean().default(true),
MAX_UPLOAD_SIZE_MB: z.coerce.number().int().positive().default(10),
// URLs
APP_URL: z.string().url().default("http://localhost:3000"),
NEXT_PUBLIC_API_URL: z.string().url().optional(),
})
// Validate at startup — throws with clear error if misconfigured
function parseEnv() {
const result = envSchema.safeParse(process.env)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
const messages = Object.entries(errors)
.map(([key, msgs]) => ` ${key}: ${msgs?.join(", ")}`)
.join("\n")
console.error(`\n❌ Invalid environment variables:\n${messages}\n`)
process.exit(1)
}
return result.data
}
export const env = parseEnv()
// Type-safe access:
// env.DATABASE_URL ← string (url)
// env.PORT ← number
// env.NODE_ENV ← "development" | "test" | "production"
Custom Error Map for i18n
// lib/validation/errorMap.ts — localized Zod error messages
import { z } from "zod"
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
if (issue.expected === "string") return { message: "This field is required" }
if (issue.expected === "number") return { message: "Must be a valid number" }
break
case z.ZodIssueCode.too_small:
if (issue.type === "string") {
if (issue.minimum === 1) return { message: "This field is required" }
return { message: `Must be at least ${issue.minimum} characters` }
}
if (issue.type === "number") return { message: `Must be at least ${issue.minimum}` }
if (issue.type === "array") return { message: `Must have at least ${issue.minimum} items` }
break
case z.ZodIssueCode.too_big:
if (issue.type === "string") return { message: `Must be at most ${issue.maximum} characters` }
if (issue.type === "number") return { message: `Must be at most ${issue.maximum}` }
break
case z.ZodIssueCode.invalid_string:
if (issue.validation === "email") return { message: "Invalid email address" }
if (issue.validation === "url") return { message: "Invalid URL" }
if (issue.validation === "uuid") return { message: "Invalid ID format" }
break
case z.ZodIssueCode.custom:
return { message: issue.message ?? "Invalid value" }
}
return { message: ctx.defaultError }
}
// Apply globally — affects all Zod parsing in this process
z.setErrorMap(customErrorMap)
For the Valibot alternative when a smaller bundle size (Valibot is modular, Zod is ~13KB) and tree-shakeable validators for edge deployments are critical — Valibot has a near-identical API surface to Zod but with individual imports for each validator, making it ideal for Cloudflare Workers where bundle size matters, see the Valibot guide. For the ArkType alternative when a TypeScript-native type syntax (type("string[]")) and runtime parsing speed (ArkType is significantly faster than Zod for complex schemas) are priorities — ArkType compiles schemas at definition time rather than parse time, see the ArkType guide. The Claude Skills 360 bundle includes Zod advanced skill sets covering discriminated unions, transforms, and env validation. Start with the free tier to try advanced Zod schema generation.