zod-4

Installation
SKILL.md

When to Use

Triggers: When validating data, defining schemas, working with forms, or using Zod for type safety.

Load when: validating user input, defining data schemas, working with React Hook Form + Zod, or migrating from Zod 3.

Critical Patterns — Breaking Changes from v3

// ✅ Zod 4 — top-level validators
const emailSchema = z.email();
const urlSchema = z.url();
const uuidSchema = z.uuid();

// ❌ Zod 3 (no longer works the same way)
const emailSchema = z.string().email();

// ✅ Zod 4 — min(1) instead of nonempty()
const nameSchema = z.string().min(1);

// ❌ Zod 3
const nameSchema = z.string().nonempty();

// ✅ Zod 4 — unified error
const schema = z.object({
  name: z.string({ error: 'Name is required' }),
});

// ❌ Zod 3
const schema = z.object({
  name: z.string({ required_error: 'Name is required' }),
});

Code Examples

Basic schemas

import { z } from 'zod';

// Primitives
const nameSchema = z.string().min(1).max(100);
const ageSchema = z.number().int().min(0).max(150);
const activeSchema = z.boolean();
const dateSchema = z.date();

// Zod 4 specials
const emailSchema = z.email();
const urlSchema = z.url();
const uuidSchema = z.uuid();

// Object with type inference
const UserSchema = z.object({
  id: z.uuid(),
  name: z.string().min(1),
  email: z.email(),
  age: z.number().int().min(18).optional(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.date(),
});

type User = z.infer<typeof UserSchema>;

Parse vs SafeParse

// parse() — throws ZodError on failure
try {
  const user = UserSchema.parse(data);
  // user is typed as User
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.errors);
  }
}

// safeParse() — returns result without throwing
const result = UserSchema.safeParse(data);
if (result.success) {
  const user = result.data; // typed as User
} else {
  const errors = result.error.errors;
  errors.forEach(e => console.log(e.path, e.message));
}

Arrays, Records, Tuples

// Array
const TagsSchema = z.array(z.string()).min(1).max(10);

// Record
const MetaSchema = z.record(z.string(), z.string());

// Tuple
const CoordSchema = z.tuple([z.number(), z.number()]);

// Discriminated Union
const EventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
  z.object({ type: z.literal('keypress'), key: z.string() }),
]);

Transformations and Refinements

// Transform — parse and transform
const DateStringSchema = z.string().transform((val) => new Date(val));

// Refine — custom validation
const PasswordSchema = z.string()
  .min(8)
  .refine(
    (val) => /[A-Z]/.test(val),
    { message: 'Must contain uppercase letter' }
  )
  .refine(
    (val) => /[0-9]/.test(val),
    { message: 'Must contain a number' }
  );

// superRefine — cross-field validation
const PasswordMatchSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwords must match',
      path: ['confirmPassword'],
    });
  }
});

Integration with React Hook Form

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

const LoginSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
});

type LoginForm = z.infer<typeof LoginSchema>;

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

  const onSubmit = (data: LoginForm) => {
    console.log(data); // Typed and validated
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password')} />
      {errors.password && <p>{errors.password.message}</p>}

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

Schemas for API (input/output)

// Schema to validate request body
const CreateUserInput = z.object({
  name: z.string().min(1).max(100),
  email: z.email(),
  role: z.enum(['admin', 'user']).default('user'),
});

// Schema for response (omits sensitive fields)
const UserResponse = z.object({
  id: z.uuid(),
  name: z.string(),
  email: z.email(),
  role: z.string(),
  createdAt: z.date(),
});

type CreateUserInput = z.infer<typeof CreateUserInput>;
type UserResponse = z.infer<typeof UserResponse>;

Anti-Patterns

❌ Parse without error handling

// ❌ Crashes on failure
const user = UserSchema.parse(untrustedData);

// ✅ Safe parse or try/catch
const result = UserSchema.safeParse(untrustedData);
if (!result.success) handleErrors(result.error);

❌ Using any instead of inferring

// ❌
function createUser(data: any) { ... }

// ✅
type CreateUserInput = z.infer<typeof CreateUserSchema>;
function createUser(data: CreateUserInput) { ... }

Quick Reference

Task Zod 4 Pattern
Email z.email()
URL z.url()
UUID z.uuid()
Non-empty string z.string().min(1)
Custom error z.string({ error: 'msg' })
Infer type z.infer<typeof Schema>
Safe parse Schema.safeParse(data)
Cross-field validation .superRefine()
With RHF zodResolver(Schema)

Rules

  • This skill targets Zod v4 specifically — z.string().email() and other validators changed in v4; verify the installed version before applying patterns
  • Define schemas as named constants, not inline — reusing schema definitions ensures consistency between validation and TypeScript type inference
  • Use z.infer<typeof Schema> to derive TypeScript types from schemas; manually duplicating types alongside schemas causes drift
  • safeParse is required for user input validation — parse throws by default and must only be used when an exception is the correct error handling strategy
  • Zod schemas for form validation must be defined outside the component to avoid recreation on every render
Weekly Installs
3
First Seen
Mar 22, 2026