zod-v4-patterns

📁 jchaselubitz/drill-app 📅 Jan 25, 2026
4
总安装量
2
周安装量
#53177
全站排名
安装命令
npx skills add https://github.com/jchaselubitz/drill-app --skill zod-v4-patterns

Agent 安装分布

opencode 2
codex 2
claude-code 2
gemini-cli 2
replit 1
windsurf 1

Skill 文档

Zod v4 Patterns for kove-webapp

This skill ensures consistent usage of Zod v4 patterns and prevents deprecated v3 syntax.

When to Apply

Use these patterns when:

  • Creating new validation schemas
  • Defining form validation with React Hook Form
  • Writing API request/response validators
  • Updating existing Zod schemas from v3
  • Adding custom error messages to validators

Critical Pattern Changes from v3 to v4

1. Error Customization (Most Important)

✅ v4: Use error parameter

z.string({ error: "Custom error message" })
z.number({ error: "Must be a number" })
z.boolean({ error: "Must be true or false" })

❌ v3 patterns (AVOID):

z.string({ message: "..." })
z.string({ invalid_type_error: "..." })
z.string({ required_error: "..." })

2. String Format Validators

✅ v4: Top-level functions

z.email()
z.email({ error: "Invalid email address" })

z.uuid()
z.uuid({ error: "Must be a valid UUID" })

z.url()
z.url({ error: "Must be a valid URL" })

❌ v3: Chained methods (AVOID)

z.string().email()
z.string().uuid()
z.string().url()

3. Object Strictness

✅ v4: Use constructors

// Strict: No unknown keys allowed
z.strictObject({
  name: z.string(),
  age: z.number()
})

// Loose: Allow unknown keys to pass through
z.looseObject({
  name: z.string(),
  age: z.number()
})

// Default object (strips unknown keys)
z.object({
  name: z.string(),
  age: z.number()
})

❌ v3: Chained methods (AVOID)

z.object({ ... }).strict()
z.object({ ... }).passthrough()

4. Schema Composition

✅ v4: Use .extend()

const baseSchema = z.object({
  name: z.string(),
  email: z.email()
});

const extendedSchema = baseSchema.extend({
  age: z.number(),
  phone: z.string()
});

❌ v3: .merge() (AVOID)

const extendedSchema = baseSchema.merge(additionalSchema);

5. Default Values

⚠️ Important: .default() applies AFTER validation

The default value must match the output type, not the input type:

// ✅ Correct: Default matches output type
z.string().transform(s => s.length).default(5)

// ❌ Wrong: Default doesn't match output (output is number)
z.string().transform(s => s.length).default("hello")

Use .prefault() for pre-validation defaults:

// Applies default BEFORE validation
z.string().prefault("default value")

6. Error Handling

✅ v4: Use z.prettifyError() or z.treeifyError()

const result = schema.safeParse(data);

if (!result.success) {
  // Pretty print errors for debugging
  console.error(z.prettifyError(result.error));

  // Or get tree structure
  const errorTree = z.treeifyError(result.error);
}

❌ v3: Avoid old methods

result.error.format()    // Deprecated
result.error.flatten()   // Deprecated
result.error.formErrors  // Deprecated

Common Validation Patterns

Form Schema Example

import { z } from 'zod';

const formSchema = z.object({
  // Basic string with custom error
  name: z.string({ error: "Name is required" }),

  // Email validation
  email: z.email({ error: "Invalid email address" }),

  // String with minimum length
  password: z.string({ error: "Password is required" })
    .min(8, { error: "Password must be at least 8 characters" }),

  // Optional field
  phone: z.string().optional(),

  // Number with range
  age: z.number({ error: "Age must be a number" })
    .min(18, { error: "Must be at least 18 years old" })
    .max(120, { error: "Invalid age" }),

  // Boolean with default
  terms: z.boolean({ error: "Must be true or false" })
    .refine(val => val === true, {
      message: "You must accept the terms and conditions"
    }),

  // Enum
  role: z.enum(['admin', 'user', 'guest'], {
    error: "Invalid role selected"
  }),

  // Array of strings
  tags: z.array(z.string({ error: "Each tag must be a string" }))
    .min(1, { error: "At least one tag is required" }),

  // Nested object
  address: z.object({
    street: z.string({ error: "Street is required" }),
    city: z.string({ error: "City is required" }),
    zip: z.string({ error: "ZIP code is required" })
  }),

  // UUID
  userId: z.uuid({ error: "Invalid user ID format" })
});

type FormData = z.infer<typeof formSchema>;

API Request Schema

const createLeaseSchema = z.strictObject({
  propertyId: z.uuid({ error: "Invalid property ID" }),
  tenantId: z.uuid({ error: "Invalid tenant ID" }),
  startDate: z.string({ error: "Start date is required" })
    .transform(str => new Date(str)),
  endDate: z.string({ error: "End date is required" })
    .transform(str => new Date(str)),
  monthlyRent: z.number({ error: "Monthly rent must be a number" })
    .positive({ error: "Monthly rent must be positive" }),
  deposit: z.number({ error: "Deposit must be a number" })
    .nonnegative({ error: "Deposit cannot be negative" })
}).refine(data => data.endDate > data.startDate, {
  message: "End date must be after start date",
  path: ["endDate"]
});

Server Action Validation

'use server';

import { z } from 'zod';

const inputSchema = z.object({
  organizationId: z.uuid({ error: "Invalid organization ID" }),
  name: z.string({ error: "Name is required" })
    .min(1, { error: "Name cannot be empty" }),
  email: z.email({ error: "Invalid email address" })
});

export async function createTenant(input: unknown) {
  // Validate input
  const result = inputSchema.safeParse(input);

  if (!result.success) {
    return {
      error: z.prettifyError(result.error)
    };
  }

  const validatedData = result.data;

  // Use validatedData (fully typed)
  // ...
}

React Hook Form Integration

'use client';

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

const formSchema = z.object({
  email: z.email({ error: "Invalid email address" }),
  password: z.string({ error: "Password is required" })
    .min(8, { error: "Password must be at least 8 characters" })
});

type FormValues = z.infer<typeof formSchema>;

export function LoginForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: '',
      password: ''
    }
  });

  const onSubmit = async (data: FormValues) => {
    // data is fully typed and validated
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* form fields */}
    </form>
  );
}

Migration Checklist

When updating schemas from v3 to v4:

  • Replace { message: "..." } with { error: "..." }
  • Replace { invalid_type_error: "..." } with { error: "..." }
  • Replace { required_error: "..." } with { error: "..." }
  • Replace z.string().email() with z.email()
  • Replace z.string().uuid() with z.uuid()
  • Replace z.string().url() with z.url()
  • Replace .strict() with z.strictObject()
  • Replace .passthrough() with z.looseObject()
  • Replace .merge() with .extend()
  • Replace .format() with z.prettifyError()
  • Replace .flatten() with z.prettifyError() or z.treeifyError()
  • Verify .default() values match output types
  • Consider using .prefault() for pre-validation defaults

Common Mistakes to Avoid

❌ Using v3 error syntax

// Wrong
z.string({ message: "Required" })
z.string({ invalid_type_error: "Must be string" })

// Correct
z.string({ error: "Required" })

❌ Using chained format validators

// Wrong
z.string().email()
z.string().url()

// Correct
z.email()
z.url()

❌ Using .merge() for composition

// Wrong
const extended = baseSchema.merge(additionalSchema);

// Correct
const extended = baseSchema.extend({ ...additionalFields });

❌ Wrong default type

// Wrong: default doesn't match output type (number)
z.string().transform(s => parseInt(s)).default("0")

// Correct: default matches output type
z.string().transform(s => parseInt(s)).default(0)

❌ Using deprecated error methods

// Wrong
if (!result.success) {
  const errors = result.error.flatten();
}

// Correct
if (!result.success) {
  console.error(z.prettifyError(result.error));
}

Advanced Patterns

Custom Refinements

const passwordSchema = z.string({ error: "Password is required" })
  .min(8, { error: "Password must be at least 8 characters" })
  .refine(val => /[A-Z]/.test(val), {
    message: "Password must contain at least one uppercase letter"
  })
  .refine(val => /[0-9]/.test(val), {
    message: "Password must contain at least one number"
  });

Conditional Validation

const schema = z.object({
  type: z.enum(['individual', 'company']),
  name: z.string({ error: "Name is required" }),
  companyName: z.string().optional()
}).refine(data => {
  if (data.type === 'company') {
    return !!data.companyName;
  }
  return true;
}, {
  message: "Company name is required for company type",
  path: ["companyName"]
});

Discriminated Unions

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()
  })
]);

Transform with Validation

const dateSchema = z.string({ error: "Date is required" })
  .refine(val => !isNaN(Date.parse(val)), {
    message: "Invalid date format"
  })
  .transform(val => new Date(val));

What to Check

When reviewing Zod schemas:

  1. ✅ Are error messages using { error: "..." } syntax?
  2. ✅ Are email/uuid/url validators using top-level functions?
  3. ✅ Are object strictness patterns using constructors?
  4. ✅ Is schema composition using .extend()?
  5. ✅ Do .default() values match output types?
  6. ✅ Is error handling using z.prettifyError() or z.treeifyError()?
  7. ✅ Are there any deprecated v3 patterns?
  8. ✅ Are refinements used for complex validation logic?
  9. ✅ Are TypeScript types inferred with z.infer<typeof schema>?

Resources

  • Zod v4 Documentation: Check official docs for latest patterns
  • Location: All schema definitions throughout the codebase
  • Integration: React Hook Form uses zodResolver for form validation