Skip to main content
Technology & EngineeringForm Survey Services288 lines

Zod

Define and validate data schemas with Zod for parsing, transforms, refinements,

Quick Summary23 lines
You are an expert at data validation with Zod. You define schemas that parse unknown data into typed values, compose complex schemas from primitives, apply transforms and refinements, and integrate schemas with form libraries and API layers for end-to-end type safety.

## Key Points

- **Casting with `as Type` instead of parsing**. `json as User` bypasses all validation; `userSchema.parse(json)` guarantees correctness.
- **Maintaining separate interfaces and schemas**. Use `z.infer<typeof schema>` as the type. One source of truth, zero drift.
- **Using `.parse()` when you should use `.safeParse()`**. In request handlers, `.safeParse()` lets you return 400 errors; `.parse()` throws unhandled exceptions.
- **Deeply nesting `.refine()` inside object fields** when `.superRefine()` at the object level is clearer for cross-field validation.
- Validating API request/response payloads at runtime with full TypeScript inference.
- Defining form validation schemas shared between client and server (with React Hook Form or Formik).
- Parsing environment variables, config files, or CLI arguments at startup.
- Building tRPC procedures where Zod schemas define input/output contracts.
- Replacing manual type guards and assertion functions with declarative schemas.

## Quick Example

```typescript
// npm install zod

import { z, ZodError } from "zod";
```
skilldb get form-survey-services-skills/ZodFull skill: 288 lines
Paste into your CLAUDE.md or agent config

Zod Schema Validation Skill

You are an expert at data validation with Zod. You define schemas that parse unknown data into typed values, compose complex schemas from primitives, apply transforms and refinements, and integrate schemas with form libraries and API layers for end-to-end type safety.

Core Philosophy

Parse, Don't Validate

Zod schemas are parsers, not validators. Call .parse() or .safeParse() on untrusted data and receive either a fully typed result or structured errors. Never trust runtime data — parse it at system boundaries (API routes, form submissions, environment variables, config files).

Single Source of Truth

Define the Zod schema once and infer the TypeScript type with z.infer<>. Never maintain a separate interface that duplicates the schema. When the schema changes, types update automatically. This eliminates the entire category of type-vs-validation drift bugs.

Composition Over Complexity

Build complex schemas by composing small, reusable schemas with .extend(), .merge(), .pick(), .omit(), and .intersection(). Keep individual schemas focused on one concern. A userSchema composed from emailSchema and nameSchema is easier to test than a monolithic object.

Setup

Install Zod:

// npm install zod

import { z, ZodError } from "zod";

No environment variables needed. Zod is a pure runtime library with zero dependencies.

Key Patterns

Parse Unknown Data at Boundaries

Do this — parse and narrow types at API boundaries:

const userResponseSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.string().datetime(),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

type UserResponse = z.infer<typeof userResponseSchema>;

// At the API boundary — parse the response
async function fetchUser(id: string): Promise<UserResponse> {
  const res = await fetch(`/api/users/${id}`);
  const json = await res.json();
  return userResponseSchema.parse(json); // throws ZodError if invalid
}

// Safe version that returns a discriminated union
async function fetchUserSafe(id: string) {
  const res = await fetch(`/api/users/${id}`);
  const json = await res.json();
  const result = userResponseSchema.safeParse(json);
  if (!result.success) {
    console.error("Validation failed:", result.error.flatten());
    return null;
  }
  return result.data; // fully typed UserResponse
}

Not this — casting as UserResponse on unvalidated API responses and hoping the shape is correct.

Transforms and Preprocessing

Do this — use transforms to coerce and reshape data:

// Coerce strings to proper types (common with form data and query params)
const searchParamsSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["asc", "desc"]).default("desc"),
  q: z.string().trim().optional(),
});

// Transform the parsed shape
const apiDateSchema = z.string().datetime().transform((s) => new Date(s));

const eventSchema = z.object({
  title: z.string().trim().min(1),
  startsAt: apiDateSchema,
  endsAt: apiDateSchema,
}).refine((data) => data.endsAt > data.startsAt, {
  message: "End date must be after start date",
  path: ["endsAt"],
});

type Event = z.infer<typeof eventSchema>;
// { title: string; startsAt: Date; endsAt: Date }

// Preprocess to handle null/undefined before validation
const nullableNumberSchema = z.preprocess(
  (val) => (val === "" || val === null ? undefined : val),
  z.number().optional()
);

Not this — parsing dates or coercing numbers manually after validation with ad-hoc utility functions.

Refinements for Custom Validation

Do this — add custom rules with .refine() and .superRefine():

const passwordSchema = z
  .string()
  .min(8, "At least 8 characters")
  .refine((pw) => /[A-Z]/.test(pw), "Must contain an uppercase letter")
  .refine((pw) => /[0-9]/.test(pw), "Must contain a number")
  .refine((pw) => /[^A-Za-z0-9]/.test(pw), "Must contain a special character");

const registrationSchema = z.object({
  email: z.string().email(),
  password: passwordSchema,
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"],
});

// superRefine for multiple conditional errors
const teamSchema = z.object({
  name: z.string().min(1),
  members: z.array(z.string().email()),
  plan: z.enum(["free", "pro", "enterprise"]),
}).superRefine((data, ctx) => {
  const maxMembers = { free: 3, pro: 20, enterprise: Infinity }[data.plan];
  if (data.members.length > maxMembers) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_big,
      maximum: maxMembers,
      inclusive: true,
      type: "array",
      path: ["members"],
      message: `${data.plan} plan allows max ${maxMembers} members`,
    });
  }
});

Not this — validating cross-field rules in submit handlers instead of in the schema where they belong.

Common Patterns

Schema Composition

const addressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  state: z.string().length(2),
  zip: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code"),
});

const baseUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

// Extend, pick, omit
const fullUserSchema = baseUserSchema.extend({
  address: addressSchema,
  role: z.enum(["admin", "user"]),
});

const createUserSchema = fullUserSchema.omit({ role: true });
const updateUserSchema = fullUserSchema.partial().required({ email: true });

type CreateUser = z.infer<typeof createUserSchema>;
type UpdateUser = z.infer<typeof updateUserSchema>;

Environment Variable Validation

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  PORT: z.coerce.number().int().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  API_SECRET: z.string().min(32),
});

export const env = envSchema.parse(process.env);
// Fully typed: env.DATABASE_URL is string, env.PORT is number

React Hook Form Integration

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const formSchema = z.object({
  title: z.string().min(1, "Title is required"),
  body: z.string().min(10, "Body must be at least 10 characters"),
  tags: z.array(z.string()).min(1, "Add at least one tag"),
});

type FormData = z.infer<typeof formSchema>;

function PostForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(formSchema),
  });
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("title")} />
      {errors.title && <span>{errors.title.message}</span>}
      <textarea {...register("body")} />
      {errors.body && <span>{errors.body.message}</span>}
      <button type="submit">Publish</button>
    </form>
  );
}

Discriminated Unions

const shapeSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("circle"), radius: z.number().positive() }),
  z.object({ type: z.literal("rectangle"), width: z.number().positive(), height: z.number().positive() }),
  z.object({ type: z.literal("triangle"), base: z.number().positive(), height: z.number().positive() }),
]);

type Shape = z.infer<typeof shapeSchema>;

function area(shape: Shape): number {
  switch (shape.type) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "rectangle": return shape.width * shape.height;
    case "triangle": return 0.5 * shape.base * shape.height;
  }
}

Formatted Error Messages

function formatZodErrors(error: ZodError): Record<string, string> {
  const formatted: Record<string, string> = {};
  for (const issue of error.issues) {
    const path = issue.path.join(".");
    if (!formatted[path]) {
      formatted[path] = issue.message;
    }
  }
  return formatted;
}

// Or use built-in flatten
const result = userSchema.safeParse(input);
if (!result.success) {
  const flat = result.error.flatten();
  // flat.fieldErrors: { email?: string[], name?: string[] }
  // flat.formErrors: string[]
}

Anti-Patterns

  • Casting with as Type instead of parsing. json as User bypasses all validation; userSchema.parse(json) guarantees correctness.
  • Maintaining separate interfaces and schemas. Use z.infer<typeof schema> as the type. One source of truth, zero drift.
  • Using .parse() when you should use .safeParse(). In request handlers, .safeParse() lets you return 400 errors; .parse() throws unhandled exceptions.
  • Deeply nesting .refine() inside object fields when .superRefine() at the object level is clearer for cross-field validation.

When to Use

  • Validating API request/response payloads at runtime with full TypeScript inference.
  • Defining form validation schemas shared between client and server (with React Hook Form or Formik).
  • Parsing environment variables, config files, or CLI arguments at startup.
  • Building tRPC procedures where Zod schemas define input/output contracts.
  • Replacing manual type guards and assertion functions with declarative schemas.

Install this skill directly: skilldb add form-survey-services-skills

Get CLI access →