Claude Code for Zod Advanced: Runtime Validation Patterns — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Zod Advanced: Runtime Validation Patterns
Frontend

Claude Code for Zod Advanced: Runtime Validation Patterns

Published: June 10, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free