home / skills / gentleman-programming / gentleman-skills / zod-4

zod-4 skill

/curated/zod-4

This skill helps you migrate zod v3 to v4 patterns, outlining updated validators, error handling, and schemas for reliable type-safe validation.

This is most likely a fork of the zod-4 skill from gentleman-programming
npx playbooks add skill gentleman-programming/gentleman-skills --skill zod-4

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
4.7 KB
---
name: zod-4
description: >
  Zod 4 schema validation patterns.
  Trigger: When using Zod for validation - breaking changes from v3.
license: Apache-2.0
metadata:
  author: gentleman-programming
  version: "1.0"
---

## Breaking Changes from Zod 3

```typescript
// ❌ Zod 3 (OLD)
z.string().email()
z.string().uuid()
z.string().url()
z.string().nonempty()
z.object({ name: z.string() }).required_error("Required")

// ✅ Zod 4 (NEW)
z.email()
z.uuid()
z.url()
z.string().min(1)
z.object({ name: z.string() }, { error: "Required" })
```

## Basic Schemas

```typescript
import { z } from "zod";

// Primitives
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();

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

// With constraints
const nameSchema = z.string().min(1).max(100);
const ageSchema = z.number().int().positive().max(150);
const priceSchema = z.number().min(0).multipleOf(0.01);
```

## Object Schemas

```typescript
const userSchema = z.object({
  id: z.uuid(),
  email: z.email({ error: "Invalid email address" }),
  name: z.string().min(1, { error: "Name is required" }),
  age: z.number().int().positive().optional(),
  role: z.enum(["admin", "user", "guest"]),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

type User = z.infer<typeof userSchema>;

// Parsing
const user = userSchema.parse(data);  // Throws on error
const result = userSchema.safeParse(data);  // Returns { success, data/error }

if (result.success) {
  console.log(result.data);
} else {
  console.log(result.error.issues);
}
```

## Arrays and Records

```typescript
// Arrays
const tagsSchema = z.array(z.string()).min(1).max(10);
const numbersSchema = z.array(z.number()).nonempty();

// Records (objects with dynamic keys)
const scoresSchema = z.record(z.string(), z.number());
// { [key: string]: number }

// Tuples
const coordinatesSchema = z.tuple([z.number(), z.number()]);
// [number, number]
```

## Unions and Discriminated Unions

```typescript
// Simple union
const stringOrNumber = z.union([z.string(), z.number()]);

// Discriminated union (more efficient)
const resultSchema = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: z.unknown() }),
  z.object({ status: z.literal("error"), error: z.string() }),
]);
```

## Transformations

```typescript
// Transform during parsing
const lowercaseEmail = z.email().transform(email => email.toLowerCase());

// Coercion (convert types)
const numberFromString = z.coerce.number();  // "42" → 42
const dateFromString = z.coerce.date();      // "2024-01-01" → Date

// Preprocessing
const trimmedString = z.preprocess(
  val => typeof val === "string" ? val.trim() : val,
  z.string()
);
```

## Refinements

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

// With superRefine for multiple errors
const formSchema = z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Passwords don't match",
      path: ["confirmPassword"],
    });
  }
});
```

## Optional and Nullable

```typescript
// Optional (T | undefined)
z.string().optional()

// Nullable (T | null)
z.string().nullable()

// Both (T | null | undefined)
z.string().nullish()

// Default values
z.string().default("unknown")
z.number().default(() => Math.random())
```

## Error Handling

```typescript
// Zod 4: Use 'error' param instead of 'message'
const schema = z.object({
  name: z.string({ error: "Name must be a string" }),
  email: z.email({ error: "Invalid email format" }),
  age: z.number().min(18, { error: "Must be 18 or older" }),
});

// Custom error map
const customSchema = z.string({
  error: (issue) => {
    if (issue.code === "too_small") {
      return "String is too short";
    }
    return "Invalid string";
  },
});
```

## React Hook Form Integration

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

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

type FormData = z.infer<typeof schema>;

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
    </form>
  );
}
```

## Keywords
zod, validation, schema, typescript, forms, parsing

Overview

This skill documents practical Zod 4 schema validation patterns and highlights breaking changes from Zod 3. It focuses on new top-level validators, updated error options, and idiomatic ways to build primitives, objects, arrays, unions, transformations, and refinements. Use these patterns to migrate, validate, and integrate Zod 4 safely in TypeScript and React projects.

How this skill works

The content outlines how Zod 4 replaces some chained validators with top-level functions (for example z.email(), z.uuid(), z.url()) and changes error configuration to an { error } option. It shows common schema shapes (primitives, objects, arrays, records, unions), data coercion and preprocessing, refined validations, and integration tips for React Hook Form. It also demonstrates parse vs safeParse and custom error mapping.

When to use it

  • Migrating validation code from Zod 3 to Zod 4 to avoid breaking changes.
  • Defining validation for API payloads, forms, and configuration objects in TypeScript.
  • Enforcing complex cross-field rules using superRefine and custom issues.
  • Coercing input types (strings to numbers/dates) before validation.
  • Integrating with React Hook Form or other form libraries that use resolvers.

Best practices

  • Prefer top-level validators (z.email(), z.uuid(), z.url()) for clarity and compatibility with Zod 4.
  • Use z.string().min(1) instead of z.string().nonempty() to express nonempty strings in Zod 4.
  • Prefer safeParse for runtime validation when you need to handle errors without exceptions.
  • Use z.coerce.* for safe conversions and z.preprocess for custom normalization before validation.
  • Use discriminatedUnion for tagged unions to get faster and clearer error messages.
  • Centralize custom error mapping with a single error function to keep messages consistent.

Example use cases

  • Validate incoming JSON payloads for an API endpoint and return structured issues on failure.
  • Build a form schema for React Hook Form using zodResolver to provide typed form data and error messages.
  • Create a user model schema with z.uuid(), z.email(), optional age, and metadata record types.
  • Implement password rules with multiple refine calls and cross-field equality with superRefine.
  • Coerce query parameters to numbers/dates and validate ranges before business logic.

FAQ

How do I migrate z.string().email() from Zod 3 to Zod 4?

Replace z.string().email() with the top-level z.email(). If you need a custom error, pass { error: 'message' } to z.email().

How can I return multiple field errors when two fields conflict?

Use superRefine on an object schema and call ctx.addIssue for each problem, supplying path and message so multiple issues can be reported.