zod

SKILL.md

Zod

This skill provides guidance for implementing type-safe validation using Zod v4 in TypeScript applications. It covers schema design, error handling, type inference, and migration from Zod 3.

Zod 4 Requirements

This skill is exclusively for Zod 4, which introduced breaking changes from Zod 3. All examples and recommendations use Zod 4 syntax.

Installation

npm install zod@^4.0.0

Critical Zod 4 Changes

If you encounter Zod 3 code or examples, be aware of these breaking changes:

Error Customization - Use error not message

z.string().min(5, { error: 'Too short.' });
z.string().min(5, { message: 'Too short.' });

String Formats - Use top-level functions

z.email();
z.uuid();
z.url();
z.iso.date();
z.string().email();

Object Methods - Use dedicated functions

z.strictObject({ name: z.string() });
z.looseObject({ name: z.string() });
z.object({ name: z.string() }).strict();
z.object({ name: z.string() }).passthrough();

Error Formatting - Use top-level functions

z.flattenError(error);
z.treeifyError(error);
z.prettifyError(error);
error.flatten();
error.format();

Function Schemas - New syntax

const myFn = z.function({
  input: [z.string()],
  output: z.number(),
});
const myFn = z.function().args(z.string()).returns(z.number());

Enums - Unified API

enum Color {
  Red = 'red',
  Green = 'green',
}
z.enum(Color);
z.nativeEnum(Color);

Deprecated APIs to avoid:

  • invalid_type_error and required_error parameters (use error function instead)
  • .merge() on objects (use .extend() or object spread)
  • .deepPartial() (removed, anti-pattern)
  • z.promise() (rarely needed, just await the promise)
  • Single-argument z.record() (now requires both key and value schemas)

Key Improvements in Zod 4

  • Performance: Dramatically faster parsing and validation
  • Error handling: Unified error parameter for all error customization
  • Type safety: Better TypeScript inference and type narrowing
  • Tree-shaking: Top-level functions are more tree-shakable
  • Refinements: Now stored inside schemas, not wrapper classes
  • Defaults in optional fields: Applied correctly within optional properties

Core Principles

  1. Follow coding guidelines strictly (SRP, single export per file, prefer interfaces over types)
  2. Create properly structured schema files with kebab-case naming
  3. Leverage Zod's type inference for compile-time type safety
  4. Implement proper error handling with safeParse for user-facing validations
  5. Use refinements for custom validation logic
  6. Organize schemas in a dedicated schemas directory structure

Common Use Cases

1. API Request/Response Validation

When implementing API validation:

  • Create schema files in src/schemas/ or appropriate directory
  • Export a single schema per file using kebab-case naming
  • Use .safeParse() for user input to handle errors gracefully
  • Use .parse() only when input is guaranteed to be valid
  • Infer TypeScript types from schemas using z.infer<>

Example structure:

src/
  schemas/
    user-create-request.schema.ts
    user-response.schema.ts
  interfaces/
    user.interface.ts
  functions/
    validate-user-request.ts

2. Form Validation

For form validation:

  • Create schemas matching form structure
  • Use .safeParse() to validate on submit
  • Extract and display field-specific errors using z.flattenError()
  • Implement real-time validation with debouncing if needed
  • Use refinements for cross-field validation (e.g., password confirmation)

3. Environment Variable Validation

For environment validation:

  • Create a schema in src/schemas/environment.schema.ts
  • Validate on application startup using .parse()
  • Let it throw if environment is invalid (fail-fast approach)
  • Export inferred type for use throughout the application

4. Schema Organization

Follow this structure:

  • Simple schemas: Direct export from schema file
  • Complex schemas: Build from smaller schemas
  • Shared schemas: Create base schemas and extend/pick as needed
  • Keep one schema export per file (following SRP)

5. Type Inference and Interfaces

  • Use z.infer<typeof Schema> to extract types
  • Save inferred types as interfaces in the interfaces directory
  • Use interfaces instead of inline types throughout the codebase
  • Export one interface per file with kebab-case naming

6. Error Handling Patterns

Implement these patterns:

  • User input: Use .safeParse() and handle errors gracefully
  • Internal validation: Use .parse() to fail fast on programming errors
  • Async validation: Use .parseAsync() or .safeParseAsync() with async validations
  • Custom errors: Use the error parameter for user-friendly messages
  • Formatted errors: Use z.flattenError() for forms, z.treeifyError() for nested data

7. Understanding Error Structure

When validation fails, Zod returns a ZodError instance containing an .issues array. Each issue provides granular information about what went wrong.

The Issues Array

Every validation error contains detailed metadata:

const result = schema.safeParse(invalidData);

if (!result.success) {
  result.error.issues;
}

Each issue object contains:

  • code: Error code indicating the type of validation failure

    • invalid_type: Wrong data type (e.g., expected string, got number)
    • custom: Custom validation from .refine() or .superRefine()
    • too_big: Value exceeds maximum constraint
    • too_small: Value below minimum constraint
    • unrecognized_keys: Extra keys in strict objects
    • invalid_string: String format validation failed (email, url, etc.)
    • And many more specific codes
  • path: Array showing the location of the error in nested structures

    • [] for top-level errors
    • ['username'] for object property errors
    • ['users', 0, 'email'] for nested array/object errors
  • message: Human-readable error description

  • Context-specific properties depending on error type:

    • expected and received for type mismatches
    • minimum and maximum for size constraints
    • inclusive for whether constraints are inclusive
    • keys for unrecognized keys
    • validation for string format types

Working with Issues Directly

Access the raw issues array for maximum control:

const result = UserSchema.safeParse(data);

if (!result.success) {
  result.error.issues.forEach((issue) => {
    console.log(`Error at ${issue.path.join('.')}: ${issue.message}`);
    console.log(`Error code: ${issue.code}`);

    if (issue.code === 'invalid_type') {
      console.log(`Expected ${issue.expected}, got ${issue.received}`);
    }
  });
}

Error Formatting Utilities

Instead of manually processing issues, use Zod's formatting utilities:

For flat forms (single level):

const flattened = z.flattenError(result.error);

flattened.formErrors;
flattened.fieldErrors.username;
flattened.fieldErrors.email;

For nested structures:

const tree = z.treeifyError(result.error);

tree.errors;
tree.properties?.username?.errors;
tree.properties?.favoriteNumbers?.items?.[1]?.errors;

For debugging:

const pretty = z.prettifyError(result.error);
console.log(pretty);

Common Error Response Patterns

API Response with Issues:

export const handleValidationError = (error: z.ZodError) => {
  return {
    success: false,
    errors: error.issues.map((issue) => ({
      field: issue.path.join('.'),
      message: issue.message,
      code: issue.code,
    })),
  };
};

Form Field Errors:

export const getFieldErrors = (error: z.ZodError) => {
  const formatted = z.flattenError(error);

  return {
    formErrors: formatted.formErrors,
    fieldErrors: formatted.fieldErrors,
  };
};

Detailed Error Logging:

export const logValidationError = (error: z.ZodError, context: string) => {
  error.issues.forEach((issue) => {
    logger.error({
      context,
      field: issue.path.join('.'),
      code: issue.code,
      message: issue.message,
      input: issue.input,
    });
  });
};

8. Advanced Validation with Refinements

Use refinements when:

  • Custom business logic validation is needed
  • Cross-field validation is required
  • Async validation is necessary (database lookups, API calls)
  • Complex conditional validation logic applies

9. Common Schema Patterns

Implement these patterns:

  • Optional with default: Use .optional().default(value) for optional fields (note: in Zod 4, defaults are applied even within optional fields)
  • Nullable vs optional: Use .nullable() for null values, .optional() for undefined
  • String formats: Use top-level z.email(), z.uuid(), z.url(), z.iso.datetime(), etc. (not method-based)
  • Number constraints: Use z.int(), z.int32(), z.uint32(), z.float32(), z.float64(), .min(), .max()
  • Arrays: Use .min(), .max(), .nonempty() for array validation (note: .nonempty() now infers as string[] not [string, ...string[]])
  • Tuples with rest: Use z.tuple([z.string()], z.string()) for [string, ...string[]] pattern
  • Transformations: Use .transform() to convert data types (returns ZodPipe in Zod 4)
  • Preprocessing: Use .preprocess() to normalize input before validation (returns ZodPipe in Zod 4)
  • Strict objects: Use z.strictObject() instead of z.object().strict()
  • Loose objects: Use z.looseObject() instead of z.object().passthrough()
  • Records with enums: Use z.record() for exhaustive records or z.partialRecord() for optional keys

Implementation Guidelines

Check Zod Version First

IMPORTANT: Before implementing any Zod validation, check the project's Zod version.

  1. Check package.json for the Zod version
  2. If the project uses Zod v3 (any version <4.0.0):
    • Inform the user that the project is using Zod v3
    • Ask if they would like to upgrade to Zod v4
    • If yes:
      • Upgrade to Zod v4: npm install zod@^4.0.0
      • Follow the migration guidelines in the "Migration from Zod 3" section below
      • Update all existing Zod v3 code to Zod v4 syntax before proceeding with new implementation
    • If no:
      • Inform the user that this skill is designed exclusively for Zod v4 and cannot provide accurate guidance for Zod v3
      • Abort the skill and suggest they use general TypeScript assistance or find Zod v3-specific resources
  3. If the project already uses Zod v4, proceed with implementation

When Asked to Implement Zod Validation

Critical: Use Zod 4 Syntax

  • Use error parameter (not message, invalid_type_error, or errorMap)
  • Use top-level string validators: z.email() (not z.string().email())
  • Use z.strictObject() and z.looseObject() (not .strict() or .passthrough())
  • Use z.flattenError() and z.treeifyError() (not .flatten() or .format())
  • Use unified z.enum() for both string unions and native enums
  • Use two-argument z.record(key, value) (not single argument)

Implementation Steps

  1. Assess the validation target: Request validation, response validation, form data, environment variables, etc.

  2. Create the schema file:

    • Place in appropriate schemas directory
    • Use kebab-case naming (e.g., user-login-request.schema.ts)
    • Import Zod as import * as z from "zod"
    • Export a single schema constant
    • Use only Zod 4 syntax
  3. Generate the interface file:

    • Place in interfaces directory
    • Use z.infer<typeof Schema> to extract type
    • Export as an interface (not a type)
    • One interface per file
  4. Create validation function (if needed):

    • Place in functions directory
    • Implement proper error handling
    • Use async/await syntax for async validation
    • Return structured validation results
  5. Implement error handling:

    • Use .safeParse() for user input
    • Check result.success before accessing data
    • Format errors appropriately for the use case
    • Provide helpful error messages
  6. Add custom validation (if needed):

    • Use .refine() for simple custom checks
    • Use .superRefine() for multiple custom validations
    • Add abort: true for critical validations
    • Use the when parameter to control refinement execution
  7. Handle async validation:

    • Use .refine() with async function
    • Use .parseAsync() or .safeParseAsync()
    • Implement proper error handling with try/catch
    • Use async/await syntax (not promises)

Code Quality Standards

  • No comments in new code (make names descriptive)
  • Use aliased imports from tsconfig
  • Avoid using require(), use ES6 imports
  • Use async/await instead of .then() syntax
  • One export per file
  • Follow existing patterns in the codebase

Example Implementations

Schema File

import * as z from 'zod';

export const UserCreateRequestSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.email(),
  password: z.string().min(8),
  age: z.int().min(18).optional(),
});

Interface File

import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';

export interface UserCreateRequest extends z.infer<typeof UserCreateRequestSchema> {}

Validation Function

import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';

export const validateUserCreateRequest = (data: unknown) => {
  const result = UserCreateRequestSchema.safeParse(data);

  if (!result.success) {
    return {
      success: false,
      errors: z.flattenError(result.error).fieldErrors,
    };
  }

  return {
    success: true,
    data: result.data,
  };
};

With Refinement

import * as z from 'zod';

export const PasswordConfirmSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: 'Passwords do not match',
    path: ['confirmPassword'],
  });

Async validation

import * as z from 'zod';

export const UsernameSchema = z.string().refine(
  async (username) => {
    const exists = await checkUsernameExists(username);
    return !exists;
  },
  {
    error: 'Username already taken',
  },
);

Working with Error Issues

import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';

export const validateAndLogErrors = (data: unknown) => {
  const result = UserCreateRequestSchema.safeParse(data);

  if (!result.success) {
    result.error.issues.forEach((issue) => {
      const field = issue.path.join('.');

      if (issue.code === 'invalid_type') {
        console.error(`Type error at ${field}: expected ${issue.expected}, got ${issue.received}`);
      } else if (issue.code === 'too_small') {
        console.error(`Validation error at ${field}: ${issue.message} (minimum: ${issue.minimum})`);
      } else {
        console.error(`Error at ${field}: ${issue.message}`);
      }
    });

    return {
      success: false,
      errors: result.error.issues.map((issue) => ({
        field: issue.path.join('.'),
        message: issue.message,
        code: issue.code,
      })),
    };
  }

  return {
    success: true,
    data: result.data,
  };
};

Error Response for API

import * as z from 'zod';

export const createApiErrorResponse = (error: z.ZodError) => {
  const flattened = z.flattenError(error);

  return {
    success: false,
    message: 'Validation failed',
    errors: {
      general: flattened.formErrors,
      fields: Object.entries(flattened.fieldErrors).map(([field, messages]) => ({
        field,
        messages,
      })),
    },
    issues: error.issues,
  };
};

Migration from Zod 3

If migrating existing Zod 3 code, follow this systematic approach:

Step 1: Update Error Customization

Replace message with error:

z.string().min(5, { message: 'Too short.' });
z.string().min(5, { error: 'Too short.' });

Replace invalid_type_error and required_error:

z.string({
  required_error: 'Required',
  invalid_type_error: 'Not a string',
});
z.string({
  error: (iss) => (iss.input === undefined ? 'Required' : 'Not a string'),
});

Replace errorMap with error:

z.string({
  errorMap: (issue, ctx) => ({
    message: issue.code === 'too_small' ? `Too small` : ctx.defaultError,
  }),
});
z.string({
  error: (iss) => {
    if (iss.code === 'too_small') return 'Too small';
    return undefined;
  },
});

Step 2: Update String Format Validators

z.string().email();
z.string().uuid();
z.string().url();
z.string().datetime();
z.email();
z.uuid();
z.url();
z.iso.datetime();

Step 3: Update Object Schemas

Replace .strict() and .passthrough():

z.object({ name: z.string() }).strict();
z.object({ name: z.string() }).passthrough();
z.strictObject({ name: z.string() });
z.looseObject({ name: z.string() });

Replace .merge() with .extend():

BaseSchema.merge(ExtensionSchema);
BaseSchema.extend(ExtensionSchema.shape);
z.object({
  ...BaseSchema.shape,
  ...ExtensionSchema.shape,
});

Step 4: Update Enums

enum Color {
  Red = 'red',
  Green = 'green',
}
z.nativeEnum(Color);
z.enum(Color);

Step 5: Update Error Formatting

error.flatten();
error.format();
z.flattenError(error);
z.treeifyError(error);

Step 6: Update Function Schemas

z.function().args(z.string(), z.number()).returns(z.boolean());
z.function({
  input: [z.string(), z.number()],
  output: z.boolean(),
});

Step 7: Update Record Schemas

z.record(z.string());
z.record(z.string(), z.string());

Step 8: Review Defaults in Optional Fields

Be aware that defaults are now applied within optional fields:

const schema = z.object({
  name: z.string().default('Unknown').optional(),
});

schema.parse({});

Step 9: Update Number Validations

  • Remove .safe() usage (now same as .int())
  • Replace with z.int() for safe integers
  • Be aware POSITIVE_INFINITY and NEGATIVE_INFINITY are no longer valid

Step 10: Update Issue Type References

z.ZodInvalidTypeIssue;
z.ZodTooBigIssue;
z.core.$ZodIssueInvalidType;
z.core.$ZodIssueTooBig;

Common Migration Errors to Watch For

  1. Using deprecated .message parameter - Replace with error
  2. Using method-based string validators - Use top-level functions
  3. Using .merge() on objects - Use .extend() or spread
  4. Using single-arg z.record() - Provide both key and value schemas
  5. Using z.nativeEnum() - Use unified z.enum()
  6. Calling .flatten() on errors - Use z.flattenError()
  7. Using invalid_type_error - Use error function parameter
  8. Using .deepPartial() - Remove, no replacement (anti-pattern)

Codemod Available

A community-maintained codemod is available: zod-v3-to-v4

npx zod-v3-to-v4

Note: Review all automated changes carefully as the codemod may not catch all edge cases.

Response Format

When helping with Zod validation:

  1. Acknowledge the validation requirement
  2. Verify Zod version - Use Zod 4 syntax and APIs
  3. Determine the appropriate schema structure
  4. Create the schema file following guidelines
  5. Create corresponding interface file
  6. Create validation function if needed
  7. Implement error handling with Zod 4 APIs
  8. Add any custom refinements required
  9. Use top-level functions (not deprecated methods)
  10. Test the implementation

Prioritize type safety, clear error messages, Zod 4 best practices, and adherence to the user's coding standards.

Quick Reference: Zod 3 vs Zod 4

Error Customization

Zod 3 Zod 4
z.string().min(5, { message: "..." }) z.string().min(5, { error: "..." })
z.string({ required_error: "...", invalid_type_error: "..." }) z.string({ error: (iss) => iss.input === undefined ? "..." : "..." })
z.string({ errorMap: (iss, ctx) => ({ message: "..." }) }) z.string({ error: (iss) => "..." })

String Validators

Zod 3 Zod 4
z.string().email() z.email()
z.string().uuid() z.uuid()
z.string().url() z.url()
z.string().datetime() z.iso.datetime()
z.string().date() z.iso.date()
z.string().time() z.iso.time()
z.string().duration() z.iso.duration()
z.string().ip() z.ipv4() or z.ipv6()
z.string().cidr() z.cidrv4() or z.cidrv6()

Object Schemas

Zod 3 Zod 4
z.object({ ... }).strict() z.strictObject({ ... })
z.object({ ... }).passthrough() z.looseObject({ ... })
Base.merge(Extension) Base.extend(Extension.shape) or z.object({ ...Base.shape, ...Extension.shape })
z.object({ ... }).deepPartial() Removed (no replacement)

Enums

Zod 3 Zod 4
z.nativeEnum(MyEnum) z.enum(MyEnum)
Schema.Enum.Value Removed
Schema.Values.Value Removed
Schema.enum.Value Schema.enum.Value (unchanged)

Error Formatting

Zod 3 Zod 4
error.flatten() z.flattenError(error)
error.format() z.treeifyError(error)
N/A z.prettifyError(error) (new)

Function Schemas

Zod 3 Zod 4
z.function().args(z.string()).returns(z.number()) z.function({ input: [z.string()], output: z.number() })
myFn.implement((arg) => ...) myFn.implement((arg) => ...) (unchanged)
N/A myFn.implementAsync(async (arg) => ...) (new)

Record Schemas

Zod 3 Zod 4
z.record(z.string()) z.record(z.string(), z.string()) (requires both args)
z.record(z.enum(["a", "b"]), z.number()) returns { a?: number; b?: number } Returns { a: number; b: number } (exhaustive)
N/A z.partialRecord(z.enum(["a", "b"]), z.number()) for optional keys

Number Validators

Zod 3 Zod 4
z.number().safe() z.int() (same behavior)
z.number().int() (accepts unsafe ints) z.number().int() (safe integers only)
z.number() accepts Infinity z.number() rejects Infinity
N/A z.int32(), z.uint32(), z.float32(), z.float64() (new)

Array Validators

Zod 3 Zod 4
z.array(z.string()).nonempty() infers as [string, ...string[]] Infers as string[]
N/A z.tuple([z.string()], z.string()) for [string, ...string[]]

Issue Types

Zod 3 Zod 4
z.ZodInvalidTypeIssue z.core.$ZodIssueInvalidType
z.ZodTooBigIssue z.core.$ZodIssueTooBig
z.ZodTooSmallIssue z.core.$ZodIssueTooSmall
z.ZodInvalidStringIssue z.core.$ZodIssueInvalidStringFormat
z.ZodCustomIssue z.core.$ZodIssueCustom
z.ZodInvalidEnumValueIssue z.core.$ZodIssueInvalidValue
z.ZodInvalidLiteralIssue z.core.$ZodIssueInvalidValue

Miscellaneous

Zod 3 Zod 4
z.promise(z.string()) Just await the promise
.default() applies to input type .default() applies to output type
N/A .prefault() for pre-parse default (Zod 3 behavior)
Defaults not applied in optional fields Defaults applied even in optional fields
Weekly Installs
2
GitHub Stars
1
First Seen
Feb 9, 2026
Installed on
mcpjam2
claude-code2
replit2
junie2
windsurf2
zencoder2