zod-validation

SKILL.md

Zod Validation Patterns

Core Concepts

Zod provides runtime validation with automatic TypeScript type inference — define the schema once, get the type for free:

import { z } from "zod";

const UserSchema = z.object({
    id: z.string().uuid(),
    name: z.string().min(1),
    email: z.string().email(),
    role: z.enum(["admin", "member", "viewer"]),
    createdAt: z.coerce.date(),
});

type User = z.infer<typeof UserSchema>;

Schema Design

Primitives

z.string().min(1).max(255);
z.number().int().positive();
z.boolean();
z.date();
z.literal("active");
z.enum(["small", "medium", "large"]);
z.nativeEnum(HttpStatus); // TypeScript enum

Objects

const CreateUserSchema = z.object({
    name: z.string().min(1, "Name is required"),
    email: z.string().email("Invalid email"),
    age: z.number().int().min(18).optional(),
});

// Derive variants from a base schema
const UpdateUserSchema = CreateUserSchema.partial();
const UserResponseSchema = CreateUserSchema.extend({
    id: z.string().uuid(),
    createdAt: z.coerce.date(),
});

Arrays and Records

z.array(UserSchema).min(1).max(100);
z.record(z.string(), z.number()); // Record<string, number>
z.tuple([z.string(), z.number()]); // [string, number]

Unions and Discriminated Unions

const ResultSchema = z.discriminatedUnion("status", [
    z.object({ status: z.literal("success"), data: UserSchema }),
    z.object({ status: z.literal("error"), message: z.string() }),
]);

Prefer discriminatedUnion over union — it's faster and produces better error messages.

Transforms and Coercion

const SlugSchema = z.string().transform((s) => s.toLowerCase().replace(/\s+/g, "-"));

// Coerce strings to numbers/dates (useful for form data and query params)
z.coerce.number(); // "42" → 42
z.coerce.date(); // "2025-01-01" → Date
z.coerce.boolean(); // "true" → true

Refinements

Custom validation logic:

const PasswordSchema = z
    .string()
    .min(8)
    .refine((val) => /[A-Z]/.test(val), "Must contain an uppercase letter")
    .refine((val) => /[0-9]/.test(val), "Must contain a number");

const DateRangeSchema = z
    .object({
        start: z.coerce.date(),
        end: z.coerce.date(),
    })
    .refine((data) => data.end > data.start, {
        message: "End date must be after start date",
        path: ["end"],
    });

Defaults and Preprocessing

const SettingsSchema = z.object({
    theme: z.enum(["light", "dark"]).default("light"),
    pageSize: z.number().default(20),
    notifications: z.boolean().default(true),
});

// Preprocess handles raw input before validation
const NumberFromString = z.preprocess((val) => (typeof val === "string" ? parseInt(val, 10) : val), z.number());

Parsing Patterns

Safe Parsing

const result = UserSchema.safeParse(unknownData);
if (result.success) {
    console.log(result.data); // typed as User
} else {
    console.error(result.error.flatten());
}

Use safeParse when you want to handle errors yourself. Use parse when invalid data should throw.

API Response Validation

async function fetchUser(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`);
    const json = await response.json();
    return UserSchema.parse(json);
}

Parse API responses at the boundary — everything downstream gets guaranteed types.

Paginated Response

function paginatedSchema<T extends z.ZodType>(itemSchema: T) {
    return z.object({
        items: z.array(itemSchema),
        total: z.number(),
        page: z.number(),
        pageSize: z.number(),
    });
}

const UsersResponseSchema = paginatedSchema(UserSchema);
type UsersResponse = z.infer<typeof UsersResponseSchema>;

Environment Variables

const EnvSchema = z.object({
    NODE_ENV: z.enum(["development", "production", "test"]),
    DATABASE_URL: z.string().url(),
    API_KEY: z.string().min(1),
    PORT: z.coerce.number().default(3000),
    DEBUG: z.coerce.boolean().default(false),
});

export const env = EnvSchema.parse(process.env);

Parse process.env at startup — fail fast if required variables are missing.

Form Integration

Zod integrates with React Hook Form via @hookform/resolvers:

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

const FormSchema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "Minimum 8 characters"),
});

type FormData = z.infer<typeof FormSchema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(FormSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}
      <button type="submit">Login</button>
    </form>
  );
}

Error Formatting

const result = schema.safeParse(data);
if (!result.success) {
    // Flat structure: { formErrors: string[], fieldErrors: Record<string, string[]> }
    const flat = result.error.flatten();

    // Formatted (nested): matches schema shape
    const formatted = result.error.format();
}

Schema Organization

src/
├── schemas/
│   ├── user.ts          # UserSchema, CreateUserSchema, UpdateUserSchema
│   ├── post.ts          # PostSchema, etc.
│   ├── common.ts        # PaginatedSchema, IdSchema, DateRangeSchema
│   └── env.ts           # EnvSchema
  • Co-locate schemas with their domain.
  • Export the schema and its inferred type together.
  • Build complex schemas by composing smaller ones with .extend(), .merge(), .pick(), and .omit().

Guidelines

  • Parse at boundaries (API responses, form submissions, env vars, URL params) — trust the types internally.
  • Use .safeParse() for user input. Use .parse() for data that should never be invalid (programmer errors).
  • Keep custom error messages user-friendly: "Email is required", not "Expected string, received undefined".
  • Prefer z.coerce.* over manual preprocess for simple type coercions.
  • Use discriminatedUnion over union for tagged types — better performance and error messages.
Weekly Installs
5
First Seen
Feb 25, 2026
Installed on
gemini-cli5
github-copilot5
codex5
kimi-cli5
cursor5
opencode5