Skip to main content
Technology & EngineeringForms Validation289 lines

Zod

Zod — schema declaration, parsing, type inference (z.infer), transforms, refinements, discriminated unions, error formatting, and form integration

Quick Summary24 lines
Zod is a TypeScript-first schema validation library that derives static types from runtime schemas, eliminating the duplication between type definitions and validation logic. You define a schema once and get both a validator and a TypeScript type. Zod is designed to be composable — small schemas combine into larger ones through objects, unions, intersections, and pipes. Every schema is immutable; methods return new instances. Parse functions throw on failure (`parse`) or return a discriminated result (`safeParse`), giving you full control over error handling.

## Key Points

- **Define schemas in a shared module** (e.g., `lib/schemas.ts`) and import on both client and server for consistent validation.
- **Use `z.infer`** everywhere instead of manually duplicating types — this guarantees your types stay in sync with validation.
- **Prefer `safeParse` over `parse`** in server code — catching `ZodError` with try/catch is fragile and mixes error types. `safeParse` gives a clean discriminated union.
- **Use `z.coerce`** for form inputs — HTML forms always submit strings, so `z.coerce.number()` and `z.coerce.date()` are essential.
- **Attach `path` to refinement errors** so they associate with the correct field in form integrations.
- **Keep schemas granular** — compose large schemas from small, reusable pieces with `.extend()`, `.merge()`, `.pick()`, and `.omit()`.
- **Use `.brand()`** for nominal types when you want to distinguish validated data from raw data at the type level.
- **Validating and then casting** — if you call `parse()` and then use `as SomeType` on the result, you are throwing away the type safety Zod provides. Use `z.infer` instead.
- **Putting transforms in refinements** — refinements should return booleans; use `.transform()` for data reshaping. Mixing concerns makes schemas hard to reason about.
- **Ignoring `.flatten()` for form errors** — manually iterating `error.issues` to build field-error maps is unnecessary; `flatten()` does this for you.
- **Creating circular schemas without `z.lazy`** — this will crash at module load time. Always wrap recursive references in `z.lazy()`.
- **Duplicating schemas on client and server** — maintain one source of truth in a shared package or module, not two copies that drift apart.

## Quick Example

```typescript
npm install zod
```
skilldb get forms-validation-skills/ZodFull skill: 289 lines
Paste into your CLAUDE.md or agent config

Zod

Core Philosophy

Zod is a TypeScript-first schema validation library that derives static types from runtime schemas, eliminating the duplication between type definitions and validation logic. You define a schema once and get both a validator and a TypeScript type. Zod is designed to be composable — small schemas combine into larger ones through objects, unions, intersections, and pipes. Every schema is immutable; methods return new instances. Parse functions throw on failure (parse) or return a discriminated result (safeParse), giving you full control over error handling.

Setup

npm install zod

Basic usage:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email address"),
  age: z.number().int().min(13, "Must be at least 13"),
});

// Derive the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number }

// Parse (throws ZodError on failure)
const user = UserSchema.parse({ name: "Alice", email: "a@b.com", age: 25 });

// SafeParse (returns { success, data } | { success, error })
const result = UserSchema.safeParse(unknownInput);
if (result.success) {
  console.log(result.data); // fully typed User
} else {
  console.error(result.error.flatten());
}

Key Techniques

Composing Schemas

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"),
});

const CompanySchema = z.object({
  name: z.string(),
  address: AddressSchema,
  employees: z.array(UserSchema),
});

type Company = z.infer<typeof CompanySchema>;

Transforms

Transforms let you coerce or reshape data during parsing:

const FormDataSchema = z.object({
  // HTML inputs always produce strings; coerce to number
  price: z.string().transform((val) => {
    const parsed = parseFloat(val);
    if (isNaN(parsed)) throw new Error("Not a number");
    return parsed;
  }),
  // Or use the built-in coerce helpers
  quantity: z.coerce.number().int().positive(),
  // Trim and lowercase
  email: z.string().trim().toLowerCase().email(),
  // Parse date strings
  createdAt: z.coerce.date(),
});

type FormData = z.infer<typeof FormDataSchema>;
// { price: number; quantity: number; email: string; createdAt: Date }

Refinements

Add custom validation that cannot be expressed with built-in methods:

const PasswordSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"], // attach error to confirmPassword field
  });

// superRefine for multiple custom errors
const PasswordStrengthSchema = z.string().superRefine((val, ctx) => {
  if (!/[A-Z]/.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Must contain an uppercase letter",
    });
  }
  if (!/[0-9]/.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Must contain a digit",
    });
  }
});

Discriminated Unions

Model tagged unions cleanly for API responses or polymorphic forms:

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>;

// Discriminated unions give better error messages than plain unions —
// Zod identifies the discriminator value first, then validates only
// the matching branch.

Recursive and Lazy Schemas

interface Category {
  name: string;
  children: Category[];
}

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    children: z.array(CategorySchema),
  })
);

Error Formatting

const result = UserSchema.safeParse({ name: "", email: "bad", age: 5 });

if (!result.success) {
  // Flat list of messages
  const flat = result.error.flatten();
  // { formErrors: [], fieldErrors: { name: [...], email: [...], age: [...] } }

  // Formatted nested structure
  const formatted = result.error.format();
  // { name: { _errors: ["..."] }, email: { _errors: ["..."] }, ... }

  // Custom mapping
  const mapped = result.error.issues.map((issue) => ({
    field: issue.path.join("."),
    message: issue.message,
  }));
}

Integration with React Hook Form

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

const ContactSchema = z.object({
  name: z.string().min(1, "Required"),
  email: z.string().email(),
  message: z.string().min(10, "Too short").max(500, "Too long"),
});

type ContactData = z.infer<typeof ContactSchema>;

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ContactData>({
    resolver: zodResolver(ContactSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => fetch("/api/contact", { method: "POST", body: JSON.stringify(data) }))}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <textarea {...register("message")} />
      {errors.message && <p>{errors.message.message}</p>}

      <button type="submit">Send</button>
    </form>
  );
}

Server-Side API Validation

// Next.js Route Handler example
import { NextRequest, NextResponse } from "next/server";

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive().max(100),
  notes: z.string().max(500).optional(),
});

export async function POST(req: NextRequest) {
  const body = await req.json();
  const result = CreateOrderSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }

  const order = await db.orders.create({ data: result.data });
  return NextResponse.json(order, { status: 201 });
}

Preprocess and Pipe

// preprocess runs before the schema, useful for raw input normalization
const NumericString = z.preprocess(
  (val) => (typeof val === "string" ? parseInt(val, 10) : val),
  z.number().int().positive()
);

// pipe chains schemas — output of first becomes input of second
const DateString = z.string().pipe(z.coerce.date());

Best Practices

  • Define schemas in a shared module (e.g., lib/schemas.ts) and import on both client and server for consistent validation.
  • Use z.infer everywhere instead of manually duplicating types — this guarantees your types stay in sync with validation.
  • Prefer safeParse over parse in server code — catching ZodError with try/catch is fragile and mixes error types. safeParse gives a clean discriminated union.
  • Use z.coerce for form inputs — HTML forms always submit strings, so z.coerce.number() and z.coerce.date() are essential.
  • Attach path to refinement errors so they associate with the correct field in form integrations.
  • Keep schemas granular — compose large schemas from small, reusable pieces with .extend(), .merge(), .pick(), and .omit().
  • Use .brand() for nominal types when you want to distinguish validated data from raw data at the type level.

Anti-Patterns

  • Validating and then casting — if you call parse() and then use as SomeType on the result, you are throwing away the type safety Zod provides. Use z.infer instead.
  • Putting transforms in refinements — refinements should return booleans; use .transform() for data reshaping. Mixing concerns makes schemas hard to reason about.
  • Ignoring .flatten() for form errors — manually iterating error.issues to build field-error maps is unnecessary; flatten() does this for you.
  • Creating circular schemas without z.lazy — this will crash at module load time. Always wrap recursive references in z.lazy().
  • Using z.any() or z.unknown() as an escape hatch — this defeats the purpose of schema validation. If you truly need to accept arbitrary data, use z.record() or z.passthrough() with known fields.
  • Duplicating schemas on client and server — maintain one source of truth in a shared package or module, not two copies that drift apart.

Install this skill directly: skilldb add forms-validation-skills

Get CLI access →