MCPInstall

zod

TypeScript-first schema validation library with static type inference for form validation, API validation, and runtime type checking with compile-time types.

Installs
888
Last 24h
—
Trending rank
—
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill zod
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads zod automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

Zod Validation Skill

Summary

TypeScript-first schema validation library with static type inference. Define schemas once, get runtime validation and compile-time types automatically.

When to Use

  • Form validation with type-safe data
  • API request/response validation
  • Environment variable validation
  • Runtime type checking with TypeScript inference
  • tRPC procedure inputs/outputs
  • Database schema validation (Drizzle, Prisma)

Quick Start

import { z } from 'zod';

// Define schema
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().min(18),
  role: z.enum(['user', 'admin'])
});

// Infer TypeScript type
type User = z.infer<typeof UserSchema>;

// Validate data
const result = UserSchema.safeParse(data);
if (result.success) {
  const user: User = result.data;
}
<!-- SECTION: primitives -->

Primitive Types

Basic Types

import { z } from 'zod';

// String with validation
const nameSchema = z.string()
  .min(2, "Too short")
  .max(50, "Too long")
  .trim();

const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const regexSchema = z.string().regex(/^[A-Z]{3}$/);

// Numbers
const ageSchema = z.number()
  .int("Must be integer")
  .positive()
  .min(0)
  .max(120);

const priceSchema = z.number()
  .positive()
  .multipleOf(0.01); // Currency precision

// Boolean
const isActiveSchema = z.boolean();

// Date
const createdAtSchema = z.date()
  .min(new Date('2020-01-01'))
  .max(new Date());

const dateStringSchema = z.string().datetime(); // ISO 8601
const dateOnlySchema = z.string().date(); // YYYY-MM-DD

Special Types

// Literal values
const roleSchema = z.literal('admin');
const statusSchema = z.literal('pending');

// Enums
const ColorEnum = z.enum(['red', 'green', 'blue']);
type Color = z.infer<typeof ColorEnum>; // 'red' | 'green' | 'blue'

const NativeEnum = z.nativeEnum(MyEnum); // For TypeScript enums

// Nullable and Optional
const optionalString = z.string().optional(); // string | undefined
const nullableString = z.string().nullable(); // string | null
const nullishString = z.string().nullish(); // string | null | undefined

// Default values
const countSchema = z.number().default(0);
const settingsSchema = z.object({
  theme: z.string().default('light'),
  notifications: z.boolean().default(true)
});
<!-- SECTION: objects_and_arrays -->

Objects and Arrays

Object Schemas

// Basic object
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
  age: z.number().optional()
});

// Nested objects
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string(),
  zipCode: z.string()
});

const PersonSchema = z.object({
  name: z.string(),
  address: AddressSchema,
  contacts: z.object({
    email: z.string().email(),
    phone: z.string().optional()
  })
});

// Strict vs Passthrough
const strictSchema = z.object({ name: z.string() }).strict();
// Rejects unknown keys

const passthroughSchema = z.object({ name: z.string() }).passthrough();
// Allows unknown keys

const stripSchema = z.object({ name: z.string() }).strip();
// Removes unknown keys (default)

Array Schemas

// Simple arrays
const stringArray = z.array(z.string());
const numberArray = z.array(z.number()).min(1).max(10);

// Array of objects
const UsersSchema = z.array(UserSchema);

// Non-empty arrays
const tagSchema = z.array(z.string()).nonempty("At least one tag required");

// Fixed-length arrays (tuples)
const coordinateSchema = z.tuple([z.number(), z.number()]);
type Coordinate = z.infer<typeof coordinateSchema>; // [number, number]

// Tuple with rest
const csvRowSchema = z.tuple([z.string(), z.number()]).rest(z.string());
// [string, number, ...string[]]

Records and Maps

// Record (object with dynamic keys)
const userRolesSchema = z.record(
  z.string(), // key type
  z.enum(['admin', 'user', 'guest']) // value type
);
type UserRoles = z.infer<typeof userRolesSchema>;
// { [key: string]: 'admin' | 'user' | 'guest' }

// Map
const configMapSchema = z.map(
  z.string(), // key
  z.number()  // value
);

// Set
const uniqueTagsSchema = z.set(z.string());
<!-- SECTION: type_inference -->

Type Inference

import { z } from 'zod';

// Infer output type
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  age: z.number()
});

type User = z.infer<typeof UserSchema>;
// { id: string; email: string; age: number }

// Infer input type (before transforms)
const TransformSchema = z.object({
  date: z.string().transform(s => new Date(s))
});

type Input = z.input<typeof TransformSchema>;
// { date: string }

type Output = z.output<typeof TransformSchema>;
// { date: Date }

// Using inferred types in functions
function createUser(data: User): void {
  // data is type-safe
}

function validateAndCreate(data: unknown): User | null {
  const result = UserSchema.safeParse(data);
  return result.success ? result.data : null;
}
<!-- SECTION: validation_methods -->

Validation Methods

Parse vs SafeParse

// parse() - Throws on failure
try {
  const user = UserSchema.parse(data);
  // user is type User
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error(error.issues);
  }
}

// safeParse() - Returns result object
const result = UserSchema.safeParse(data);

if (result.success) {
  const user = result.data; // type User
} else {
  const errors = result.error.issues;
  errors.forEach(err => {
    console.log(`${err.path}: ${err.message}`);
  });
}

// parseAsync() - For async refinements
const asyncResult = await UserSchema.parseAsync(data);

// safeParseAsync() - Safe async version
const asyncSafeResult = await UserSchema.safeParseAsync(data);

Partial Validation

// Check if data matches schema without throwing
const isValid = UserSchema.safeParse(data).success;

// Custom type guards
function isUser(data: unknown): data is User {
  return UserSchema.safeParse(data).success;
}

if (isUser(unknownData)) {
  // TypeScript knows unknownData is User
  console.log(unknownData.email);
}
<!-- SECTION: schema_composition -->

Schema Composition

Extending and Merging

// Extend (add fields)
const BaseUserSchema = z.object({
  id: z.string(),
  email: z.string()
});

const AdminUserSchema = BaseUserSchema.extend({
  role: z.literal('admin'),
  permissions: z.array(z.string())
});

// Merge (combine schemas)
const NameSchema = z.object({ name: z.string() });
const AgeSchema = z.object({ age: z.number() });

const PersonSchema = NameSchema.merge(AgeSchema);
// { name: string; age: number }

// Pick (select fields)
const UserIdEmail = UserSchema.pick({ id: true, email: true });

// Omit (exclude fields)
const UserWithoutId = UserSchema.omit({ id: true });

// Partial (make all fields optional)
const PartialUser = UserSchema.partial();

// DeepPartial (recursive partial)
const DeepPartialUser = UserSchema.deepPartial();

// Required (make all fields required)
const RequiredUser = UserSchema.required();

Union and Intersection

// Union (OR)
const StringOrNumber = z.union([z.string(), z.number()]);
// Shorthand
const StringOrNumberAlt = z.string().or(z.number());

// Discriminated Union (tagged union)
const SuccessResponse = z.object({
  status: z.literal('success'),
  data: z.any()
});

const ErrorResponse = z.object({
  status: z.literal('error'),
  message: z.string()
});

const ApiResponse = z.discriminatedUnion('status', [
  SuccessResponse,
  ErrorResponse
]);

// Intersection (AND)
const User = z.object({ name: z.string() });
const Timestamps = z.object({
  createdAt: z.date(),
  updatedAt: z.date()
});

const UserWithTimestamps = z.intersection(User, Timestamps);
// Shorthand
const UserWithTimestampsAlt = User.and(Timestamps);
<!-- SECTION: transformations -->

Transformations and Refinements

Transform

// Transform data after validation
const StringToNumber = z.string().transform(val => parseInt(val, 10));

const DateSchema = z.string().transform(str => new Date(str));

// Chaining transforms
const TrimmedLowercase = z.string()
  .transform(s => s.trim())
  .transform(s => s.toLowerCase());

// Transform with validation
const PositiveStringNumber = z.string()
  .transform(val => parseInt(val, 10))
  .refine(n => n > 0, "Must be positive");

// Complex transformations
const UserInputSchema = z.object({
  name: z.string().transform(s => s.trim()),
  email: z.string().email().transform(s => s.toLowerCase()),
  birthDate: z.string().transform(s => new Date(s)),
  tags: z.string().transform(s => s.split(',').map(t => t.trim()))
});

type UserInput = z.input<typeof UserInputSchema>;
// { name: string; email: string; birthDate: string; tags: string }

type User = z.output<typeof UserInputSchema>;
// { name: string; email: string; birthDate: Date; tags: string[] }

Refine (Custom Validation)

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

// Refinement with custom error
const UniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  { message: "Email already taken" }
);

// Object-level refinement
const PasswordMatchSchema = z.object({
  password: z.string(),
  confirmPassword: z.string()
}).refine(
  data => data.password === data.confirmPassword,
  {
    message: "Passwords don't match",
    path: ["confirmPassword"] // Error location
  }
);

// Multiple field validation
const DateRangeSchema = z.object({
  startDate: z.date(),
  endDate: z.date()
}).refine(
  data => data.endDate > data.startDate,
  {
    message: "End date must be after start date",
    path: ["endDate"]
  }
);

SuperRefine (Advanced)

// Access to Zod context for complex validation
const ComplexSchema = z.object({
  type: z.enum(['email', 'phone']),
  value: z.string()
}).superRefine((data, ctx) => {
  if (data.type === 'email') {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(data.value)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Invalid email format",
        path: ["value"]
      });
    }
  } else if (data.type === 'phone') {
    const phoneRegex = /^\+?[1-9]\d{1,14}$/;
    if (!phoneRegex.test(data.value)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Invalid phone format",
        path: ["value"]
      });
    }
  }
});

// Multiple issues
const RegistrationSchema = z.object({
  username: z.string(),
  email: z.string(),
  age: z.number()
}).superRefine(async (data, ctx) => {
  // Check username availability
  if (await usernameTaken(data.username)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Username taken",
      path: ["username"]
    });
  }

  // Check email availability
  if (await emailTaken(data.email)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Email already registered",
      path: ["email"]
    });
  }

  // Age restriction
  if (data.age < 18) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Must be 18 or older",
      path: ["age"]
    });
  }
});
<!-- SECTION: error_handling -->

Error Handling

Custom Error Messages

// Field-level messages
const UserSchema = z.object({
  email: z.string().email({ message: "Invalid email address" }),
  age: z.number({
    required_error: "Age is required",
    invalid_type_error: "Age must be a number"
  }).min(18, { message: "Must be 18 or older" })
});

// Global error map
import { z } from 'zod';

const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.expected === "string") {
      return { message: "This field must be text" };
    }
  }
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === "string") {
      return { message: `Minimum ${issue.minimum} characters required` };
    }
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

Processing Errors

// Flatten errors for forms
const result = UserSchema.safeParse(data);

if (!result.success) {
  const flatErrors = result.error.flatten();

  console.log(flatErrors.formErrors); // Top-level errors
  console.log(flatErrors.fieldErrors);
  // { email: ["Invalid email"], age: ["Must be 18+"] }
}

// Format for API response
function formatZodError(error: z.ZodError) {
  return error.issues.map(issue => ({
    field: issue.path.join('.'),
    message: issue.message
  }));
}

// Example usage
const result = UserSchema.safeParse(data);
if (!result.success) {
  return res.status(400).json({
    errors: formatZodError(result.error)
  });
}
<!-- SECTION: async_validation -->

Async Validation

import { z } from 'zod';

// Async refinement
const UsernameSchema = z.string().refine(
  async (username) => {
    const available = await checkUsernameAvailable(username);
    return available;
  },
  { message: "Username already taken" }
);

// Must use parseAsync or safeParseAsync
const result = await UsernameSchema.safeParseAsync("john_doe");

// Complex async validation
const RegistrationSchema = z.object({
  username: z.string().refine(
    async (val) => !(await usernameTaken(val)),
    "Username taken"
  ),
  email: z.string().email().refine(
    async (val) => !(await emailTaken(val)),
    "Email already registered"
  ),
  inviteCode: z.string().refine(
    async (code) => await validateInviteCode(code),
    "Invalid invite code"
  )
});

// Validate
const userData = await RegistrationSchema.parseAsync(input);

// With error handling
const result = await RegistrationSchema.safeParseAsync(input);
if (!result.success) {
  // Handle validation errors
}
<!-- SECTION: advanced_types -->

Advanced Types

Recursive Types

// Self-referential schemas
type Category = {
  name: string;
  subcategories: Category[];
};

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    subcategories: z.array(CategorySchema)
  })
);

// Tree structure
type TreeNode = {
  value: number;
  left?: TreeNode;
  right?: TreeNode;
};

const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    value: z.number(),
    left: TreeNodeSchema.optional(),
    right: TreeNodeSchema.optional()
  })
);

Discriminated Unions

// Type-safe union based on discriminator field
const Circle = z.object({
  kind: z.literal('circle'),
  radius: z.number()
});

const Rectangle = z.object({
  kind: z.literal('rectangle'),
  width: z.number(),
  height: z.number()
});

const Triangle = z.object({
  kind: z.literal('triangle'),
  base: z.number(),
  height: z.number()
});

const Shape = z.discriminatedUnion('kind', [
  Circle,
  Rectangle,
  Triangle
]);

type Shape = z.infer<typeof Shape>;

// TypeScript can narrow based on discriminator
function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
  }
}

Preprocess

// Transform before validation
const NumberFromString = z.preprocess(
  (val) => (typeof val === 'string' ? parseInt(val, 10) : val),
  z.number()
);

// Clean data before validation
const TrimmedString = z.preprocess(
  (val) => (typeof val === 'string' ? val.trim() : val),
  z.string()
);

// Parse JSON strings
const JsonSchema = z.preprocess(
  (val) => (typeof val === 'string' ? JSON.parse(val) : val),
  z.object({
    name: z.string(),
    age: z.number()
  })
);

// Form data preprocessing
const FormDataSchema = z.preprocess(
  (data) => {
    // Convert FormData to object
    if (data instanceof FormData) {
      return Object.fromEntries(data.entries());
    }
    return data;
  },
  z.object({
    name: z.string(),
    email: z.string().email()
  })
);

Branded Types

// Create nominal types
const UserId = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserId>;

const Email = z.string().email().brand<'Email'>();
type Email = z.infer<typeof Email>;

// Prevents mixing similar types
function getUserById(id: UserId) { /* ... */ }
function sendEmail(to: Email) { /* ... */ }

const userId = UserId.parse('123e4567-e89b-12d3-a456-426614174000');
const email = Email.parse('user@example.com');

getUserById(userId); // ✓
getUserById(email);  // ✗ Type error
<!-- SECTION: api_handler_patterns -->

API Handler Patterns

Generic Validated Handler

Create type-safe API handlers with automatic validation, error formatting, and authentication.

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

// Generic handler type with validated input
type ValidatedHandler<T, C = T> = (
  state: {
    input: T;
    request: NextRequest;
    ctx: RouteContext<C>;
  }
) => Promise<Response> | Response;

// Route context interface
interface RouteContext<T = unknown> {
  params: T;
}

// Validation configuration
interface ValidationConfig<T extends z.ZodType, C extends z.ZodType> {
  input?: {
    schema: T;
    source: 'body' | 'query' | 'params';
  };
  auth?: {
    role: 'admin' | 'provider' | 'user';
  };
  context?: C;
}

// Generic validated handler wrapper
export function validatedHandler<
  T extends z.ZodType,
  C extends z.ZodType = z.ZodType<any>
>(
  config: ValidationConfig<T, C>,
  handler: ValidatedHandler<z.infer<T>, z.infer<C>>
) {
  return async (
    request: NextRequest,
    ctx: RouteContext<z.infer<C>>
  ): Promise<Response> => {
    try {
      // Extract input based on source
      let rawInput: unknown;

      if (config.input) {
        switch (config.input.source) {
          case 'body':
            rawInput = await request.json();
            break;
          case 'query':
            rawInput = Object.fromEntries(request.nextUrl.searchParams);
            break;
          case 'params':
            rawInput = ctx.params;
            break;
        }

        // Validate input
        const result = config.input.schema.safeParse(rawInput);

        if (!result.success) {
          return NextResponse.json(
            {
              error: 'Validation failed',
              details: result.error.issues.map(err => ({
                path: err.path.join('.'),
                message: err.message,
                code: err.code,
              })),
            },
            { status: 400 }
          );
        }

        // Call handler with validated input
        return handler({
          input: result.data,
          request,
          ctx
        });
      }

      // No validation required
      return handler({
        input: {} as z.infer<T>,
        request,
        ctx
      });

    } catch (error) {
      console.error('Handler error:', error);
      return NextResponse.json(
        { error: 'Internal server error' },
        { status: 500 }
      );
    }
  };
}

Usage Example:

// Define validation schema
const CreateCampSchema = z.object({
  name: z.string().min(3),
  location: z.string(),
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
  capacity: z.number().positive(),
});

// Type-safe route handler
export const POST = validatedHandler(
  {
    input: {
      schema: CreateCampSchema,
      source: 'body',
    },
    auth: { role: 'admin' },
  },
  async ({ input, request }) => {
    // input is fully typed as z.infer<typeof CreateCampSchema>
    const camp = await createCamp({
      name: input.name,
      location: input.location,
      startDate: new Date(input.startDate),
      endDate: new Date(input.endDate),
      capacity: input.capacity,
    });

    return NextResponse.json(camp, { status: 201 });
  }
);

Discriminated Unions for Complex Types

Use discriminated unions to handle multiple input types with type-safe narrowing.

// Location selection with discriminated union
const LocationSelectionSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('map-bounds'),
    north: z.number().min(-90).max(90),
    south: z.number().min(-90).max(90),
    east: z.number().min(-180).max(180),
    west: z.number().min(-180).max(180),
  }),
  z.object({
    type: z.literal('address'),
    address: z.string().min(1),
    radius: z.number().positive().optional(),
  }),
  z.object({
    type: z.literal('user-location'),
    lat: z.number().min(-90).max(90),
    lng: z.number().min(-180).max(180),
    radius: z.number().positive().default(10),
  }),
]);

type LocationSelection = z.infer<typeof LocationSelectionSchema>;

// Type-safe handler with union narrowing
function processLocation(selection: LocationSelection) {
  switch (selection.type) {
    case 'map-bounds':
      return searchByBounds({
        north: selection.north,
        south: selection.south,
        east: selection.east,
        west: selection.west,
      });

    case 'address':
      return searchByAddress({
        address: selection.address,
        radius: selection.radius,
      });

    case 'user-location':
      return searchByCoordinates({
        lat: selection.lat,
        lng: selection.lng,
        radius: selection.radius,
      });
  }
}

Complete Type Mapping with Required

Ensure all keys are mapped with TypeScript's Required utility type.

import { NumberParam, StringParam, DateParam } from 'next-query-params';

// Filters interface
interface Filters {
  startDate?: Date;
  endDate?: Date;
  status?: 'active' | 'inactive' | 'pending';
  search?: string;
  minPrice?: number;
  maxPrice?: number;
}

// Query param config interface
interface QueryParamConfig<T> {
  encode: (value: T) => string;
  decode: (value: string | undefined) => T | undefined;
}

// Complete mapping enforced by Required<Filters>
const filtersQueryParamConfigMap: {
  [Key in keyof Required<Filters>]: QueryParamConfig<Filters[Key]>;
} = {
  startDate: DateParam,
  endDate: DateParam,
  status: StringParam,
  search: StringParam,
  minPrice: NumberParam,
  maxPrice: NumberParam,
  // TypeScript error if any key is missing
};

// Zod schema matching the interface
const FiltersSchema = z.object({
  startDate: z.date().optional(),
  endDate: z.date().optional(),
  status: z.enum(['active', 'inactive', 'pending']).optional(),
  search: z.string().optional(),
  minPrice: z.number().positive().optional(),
  maxPrice: z.number().positive().optional(),
});

// Validation with cross-field checks
const ValidatedFiltersSchema = FiltersSchema.refine(
  (data) => {
    if (data.startDate && data.endDate) {
      return data.endDate >= data.startDate;
    }
    return true;
  },
  {
    message: 'End date must be after start date',
    path: ['endDate'],
  }
).refine(
  (data) => {
    if (data.minPrice && data.maxPrice) {
      return data.maxPrice >= data.minPrice;
    }
    return true;
  },
  {
    message: 'Max price must be greater than min price',
    path: ['maxPrice'],
  }
);

Structured Error Response Format

Create consistent, type-safe error responses.

// Error response schema
const ApiErrorSchema = z.object({
  code: z.enum([
    'VALIDATION_ERROR',
    'AUTHENTICATION_ERROR',
    'AUTHORIZATION_ERROR',
    'NOT_FOUND',
    'INTERNAL_ERROR',
  ]),
  message: z.string(),
  details: z.array(
    z.object({
      path: z.string(),
      message: z.string(),
      code: z.string().optional(),
    })
  ).optional(),
  timestamp: z.string().datetime(),
});

type ApiError = z.infer<typeof ApiErrorSchema>;

// Format Zod errors for API response
function formatZodError(error: z.ZodError): ApiError {
  return {
    code: 'VALIDATION_ERROR',
    message: 'Input validation failed',
    details: error.issues.map(issue => ({
      path: issue.path.join('.'),
      message: issue.message,
      code: issue.code,
    })),
    timestamp: new Date().toISOString(),
  };
}

// Error response helper
function errorResponse(
  code: ApiError['code'],
  message: string,
  status: number,
  details?: ApiError['details']
): Response {
  const error: ApiError = {
    code,
    message,
    details,
    timestamp: new Date().toISOString(),
  };

  return NextResponse.json(error, { status });
}

// Usage in handler
export const POST = validatedHandler(
  {
    input: { schema: CreateUserSchema, source: 'body' },
  },
  async ({ input }) => {
    try {
      const user = await createUser(input);
      return NextResponse.json(user, { status: 201 });
    } catch (error) {
      if (error instanceof z.ZodError) {
        return NextResponse.json(
          formatZodError(error),
          { status: 400 }
        );
      }

      return errorResponse(
        'INTERNAL_ERROR',
        'Failed to create user',
        500
      );
    }
  }
);

Query Parameter Transformation

Handle query parameter parsing with validation and transformation.

// Query param schema with transforms
const SearchParamsSchema = z.object({
  // String to number with validation
  page: z
    .string()
    .transform(Number)
    .pipe(z.number().int().positive().default(1)),

  // String to number with limits
  pageSize: z
    .string()
    .transform(Number)
    .pipe(z.number().int().min(1).max(100).default(20)),

  // CSV string to array
  tags: z
    .string()
    .optional()
    .transform(val => val ? val.split(',').map(t => t.trim()) : []),

  // ISO date string to Date
  startDate: z
    .string()
    .datetime()
    .optional()
    .transform(val => val ? new Date(val) : undefined),

  // Boolean string to boolean
  includeInactive: z
    .string()
    .optional()
    .transform(val => val === 'true')
    .pipe(z.boolean().default(false)),

  // Enum validation
  sortBy: z
    .enum(['name', 'date', 'price'])
    .default('date'),

  // Sort order
  sortOrder: z
    .enum(['asc', 'desc'])
    .default('desc'),
});

// Handler with query param validation
export const GET = validatedHandler(
  {
    input: {
      schema: SearchParamsSchema,
      source: 'query',
    },
  },
  async ({ input }) => {
    // All params are properly typed and transformed
    const results = await searchItems({
      page: input.page,              // number
      pageSize: input.pageSize,      // number
      tags: input.tags,              // string[]
      startDate: input.startDate,    // Date | undefined
      includeInactive: input.includeInactive, // boolean
      sortBy: input.sortBy,          // 'name' | 'date' | 'price'
      sortOrder: input.sortOrder,    // 'asc' | 'desc'
    });

    return NextResponse.json(results);
  }
);

Schema Composition for Reusable Validation

Build complex schemas from reusable parts.

// Base schemas
const TimestampSchema = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});

const PaginationSchema = z.object({
  page: z.number().int().positive(),
  pageSize: z.number().int().min(1).max(100),
  total: z.number().int().nonnegative(),
});

const LocationSchema = z.object({
  lat: z.number().min(-90).max(90),
  lng: z.number().min(-180).max(180),
  address: z.string().optional(),
});

// Composed schemas
const CampSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(3),
  location: LocationSchema,
  capacity: z.number().positive(),
  status: z.enum(['active', 'inactive', 'full']),
}).merge(TimestampSchema);

const CampListResponseSchema = z.object({
  camps: z.array(CampSchema),
  pagination: PaginationSchema,
});

// Type inference works across composition
type Camp = z.infer<typeof CampSchema>;
type CampListResponse = z.infer<typeof CampListResponseSchema>;

// Usage in handler
export const GET = validatedHandler(
  {
    input: {
      schema: z.object({
        page: z.number().default(1),
        pageSize: z.number().default(20),
      }),
      source: 'query',
    },
  },
  async ({ input }) => {
    const camps = await getCamps(input.page, input.pageSize);

    // Response validated against schema
    const response: CampListResponse = {
      camps,
      pagination: {
        page: input.page,
        pageSize: input.pageSize,
        total: await getCampCount(),
      },
    };

    return NextResponse.json(response);
  }
);
<!-- SECTION: integrations -->

Integrations

React Hook Form

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

const FormSchema = z.object({
  username: z.string().min(3, "Minimum 3 characters"),
  email: z.string().email("Invalid email"),
  age: z.number().min(18, "Must be 18+")
});

type FormData = z.infer<typeof FormSchema>;

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

  const onSubmit = (data: FormData) => {
    // data is validated and typed
    console.log(data);
  };

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

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age && <span>{errors.age.message}</span>}

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

tRPC

import { z } from 'zod';
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

const router = t.router;
const publicProcedure = t.procedure;

// Input/output validation
const appRouter = router({
  userById: publicProcedure
    .input(z.object({
      id: z.string().uuid()
    }))
    .output(z.object({
      id: z.string().uuid(),
      name: z.string(),
      email: z.string().email()
    }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({
        where: { id: input.id }
      });
      return user; // Type-checked against output schema
    }),

  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(2),
      email: z.string().email(),
      age: z.number().min(18)
    }))
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input });
    })
});

export type AppRouter = typeof appRouter;

Next.js API Routes

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(18).optional()
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const validatedData = CreateUserSchema.parse(body);

    // validatedData is typed and validated
    const user = await createUser(validatedData);

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { errors: error.flatten().fieldErrors },
        { status: 400 }
      );
    }
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// Query parameter validation
const SearchParamsSchema = z.object({
  page: z.string().transform(Number).pipe(z.number().min(1)).default('1'),
  limit: z.string().transform(Number).pipe(z.number().max(100)).default('10'),
  sort: z.enum(['asc', 'desc']).default('asc')
});

export async function GET(request: NextRequest) {
  const searchParams = Object.fromEntries(
    request.nextUrl.searchParams.entries()
  );

  const params = SearchParamsSchema.parse(searchParams);
  // params is { page: number, limit: number, sort: 'asc' | 'desc' }

  const users = await getUsers(params);
  return NextResponse.json(users);
}

Express Middleware

import express from 'express';
import { z } from 'zod';

// Validation middleware
const validate = (schema: z.ZodSchema) => {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    try {
      schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        return res.status(400).json({
          errors: error.flatten().fieldErrors
        });
      }
      next(error);
    }
  };
};

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(18)
});

app.post('/users', validate(CreateUserSchema), async (req, res) => {
  // req.body is validated (not typed in Express)
  const user = await createUser(req.body);
  res.json(user);
});

// Validate params, query, body
const validateRequest = (schema: {
  params?: z.ZodSchema;
  query?: z.ZodSchema;
  body?: z.ZodSchema;
}) => {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    try {
      if (schema.params) {
        req.params = schema.params.parse(req.params);
      }
      if (schema.query) {
        req.query = schema.query.parse(req.query);
      }
      if (schema.body) {
        req.body = schema.body.parse(req.body);
      }
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        return res.status(400).json({ errors: error.issues });
      }
      next(error);
    }
  };
};

app.get(
  '/users/:id',
  validateRequest({
    params: z.object({ id: z.string().uuid() }),
    query: z.object({ include: z.string().optional() })
  }),
  async (req, res) => {
    // Validated params and query
  }
);

Drizzle ORM

import { z } from 'zod';
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';

// Define table
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  age: integer('age')
});

// Auto-generate schemas
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);

// Customize validation
export const customInsertUserSchema = createInsertSchema(users, {
  email: z.string().email(),
  age: z.number().min(18).optional()
});

// Use in application
type NewUser = z.infer<typeof insertUserSchema>;
type User = z.infer<typeof selectUserSchema>;

function createUser(data: unknown) {
  const validatedData = insertUserSchema.parse(data);
  return db.insert(users).values(validatedData);
}

Environment Variables

// env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(32),
  PORT: z.string().transform(Number).pipe(z.number().min(1024)),
  REDIS_HOST: z.string().default('localhost'),
  REDIS_PORT: z.string().transform(Number).default('6379'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info')
});

// Validate on startup
export const env = envSchema.parse(process.env);

// Type-safe environment variables
export type Env = z.infer<typeof envSchema>;

// Usage
console.log(`Server running on port ${env.PORT}`);
// env.PORT is number, not string
<!-- SECTION: best_practices -->

Best Practices

Schema Organization

// schemas/user.schema.ts
import { z } from 'zod';

// Reusable primitives
export const emailSchema = z.string().email();
export const uuidSchema = z.string().uuid();
export const passwordSchema = z.string()
  .min(8)
  .regex(/[A-Z]/, "Must contain uppercase")
  .regex(/[0-9]/, "Must contain number");

// Base schemas
export const baseUserSchema = z.object({
  id: uuidSchema,
  email: emailSchema,
  name: z.string().min(2)
});

// Extended schemas
export const createUserSchema = baseUserSchema.omit({ id: true }).extend({
  password: passwordSchema,
  confirmPassword: z.string()
}).refine(
  data => data.password === data.confirmPassword,
  { message: "Passwords must match", path: ["confirmPassword"] }
);

export const updateUserSchema = baseUserSchema.partial().omit({ id: true });

// Export types
export type User = z.infer<typeof baseUserSchema>;
export type CreateUser = z.infer<typeof createUserSchema>;
export type UpdateUser = z.infer<typeof updateUserSchema>;

Performance Optimization

// Cache parsed schemas
const userSchemaCache = new Map<string, z.ZodSchema>();

function getCachedSchema(key: string, factory: () => z.ZodSchema) {
  if (!userSchemaCache.has(key)) {
    userSchemaCache.set(key, factory());
  }
  return userSchemaCache.get(key)!;
}

// Lazy validation for large objects
const lazyUserSchema = z.lazy(() => z.object({
  // Only validated when accessed
  profile: complexProfileSchema,
  settings: complexSettingsSchema
}));

// Streaming validation for arrays
async function validateLargeArray(items: unknown[]) {
  const errors: z.ZodError[] = [];

  for (const item of items) {
    const result = ItemSchema.safeParse(item);
    if (!result.success) {
      errors.push(result.error);
    }
  }

  return errors;
}

Testing Schemas

import { describe, it, expect } from 'vitest';

describe('UserSchema', () => {
  it('validates correct user data', () => {
    const validUser = {
      email: 'user@example.com',
      name: 'John Doe',
      age: 25
    };

    expect(() => UserSchema.parse(validUser)).not.toThrow();
  });

  it('rejects invalid email', () => {
    const invalidUser = {
      email: 'not-an-email',
      name: 'John',
      age: 25
    };

    const result = UserSchema.safeParse(invalidUser);
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].path).toEqual(['email']);
    }
  });

  it('applies transforms correctly', () => {
    const input = {
      name: '  JOHN DOE  ',
      email: 'USER@EXAMPLE.COM'
    };

    const result = UserSchema.parse(input);
    expect(result.name).toBe('john doe');
    expect(result.email).toBe('user@example.com');
  });
});

Common Patterns

// Conditional validation
const ConditionalSchema = z.object({
  type: z.enum(['personal', 'business']),
  data: z.any()
}).transform((val) => {
  if (val.type === 'personal') {
    return {
      type: val.type,
      data: PersonalDataSchema.parse(val.data)
    };
  } else {
    return {
      type: val.type,
      data: BusinessDataSchema.parse(val.data)
    };
  }
});

// Pagination schema
export const paginationSchema = z.object({
  page: z.number().min(1).default(1),
  limit: z.number().min(1).max(100).default(20),
  sort: z.string().optional(),
  order: z.enum(['asc', 'desc']).default('asc')
});

// Filter schema
export const filterSchema = z.object({
  search: z.string().optional(),
  status: z.enum(['active', 'inactive', 'pending']).optional(),
  dateFrom: z.string().datetime().optional(),
  dateTo: z.string().datetime().optional()
});

// API response wrapper
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
  z.object({
    success: z.boolean(),
    data: dataSchema.optional(),
    error: z.string().optional(),
    timestamp: z.string().datetime()
  });

const userResponseSchema = apiResponseSchema(UserSchema);

Migration from Yup/Joi

// Yup -> Zod
// Yup
const yupSchema = yup.object({
  email: yup.string().email().required(),
  age: yup.number().min(18).required()
});

// Zod equivalent
const zodSchema = z.object({
  email: z.string().email(),
  age: z.number().min(18)
});

// Joi -> Zod
// Joi
const joiSchema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().min(18).required()
});

// Zod equivalent (same as above)
const zodSchema = z.object({
  email: z.string().email(),
  age: z.number().min(18)
});

// Key differences:
// 1. Zod fields are required by default
// 2. Zod has first-class TypeScript integration
// 3. Zod schemas are immutable
// 4. Zod has better tree-shaking

Additional Resources

FAQ

What does the zod skill do?

TypeScript-first schema validation library with static type inference for form validation, API validation, and runtime type checking with compile-time types.

How do I install zod?

Run npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill zod in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/zod/ (personal) or .claude/skills/zod/ (project).

Which AI agents can use zod?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is zod safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains zod?

It is published by bobmatnyc in bobmatnyc/claude-mpm-skills. It has 888 recorded installs.

More from bobmatnyc/claude-mpm-skills

View all →

drizzle-orm

bobmatnyc/claude-mpm-skills

Type-safe SQL ORM for TypeScript with zero runtime overhead

Data & Databases4.3K installs

playwright-e2e-testing

bobmatnyc/claude-mpm-skills

Playwright modern end-to-end testing framework with cross-browser automation, auto-wait, and built-in test runner

Testing & QA2.7K installs

drizzle

bobmatnyc/claude-mpm-skills

Type-safe SQL ORM for TypeScript with zero runtime overhead

Data & Databases1.8K installs

pytest

bobmatnyc/claude-mpm-skills

pytest - Python's most powerful testing framework with fixtures, parametrization, plugins, and framework integration for FastAPI, Django, Flask

Testing & QA1.3K installs

drizzle-migrations

bobmatnyc/claude-mpm-skills

Migration-first database development workflow using Drizzle ORM for TypeScript/J...

Data & Databases1.3K installs

tailwind-css

bobmatnyc/claude-mpm-skills

Tailwind CSS utility-first framework for rapid UI development with responsive design and dark mode

Frontend & Design1.2K installs

Popular backend & apis skills

View category →

find-skills

vercel-labs/skills

Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.

OfficialBackend & APIs4.1M installs

entra-app-registration

microsoft/azure-skills

Guides Microsoft Entra ID app registration, OAuth 2.0 authentication, and MSAL integration. USE FOR: create app registration, register Azure AD app, configure OAuth, set up authentication, add API permissions, generate service principal, MSAL example, console app auth, Entra ID setup, Azure AD authentication. DO NOT USE FOR: Key Vault secrets (use azure-keyvault-expiration-audit), general Azure resource security guidance.

OfficialBackend & APIs566.7K installs

This skill should be used when the user wants to "write agent code", "build an agent with ADK", "add a tool", "create a callback", "define an agent", "use state management" — in a project that needs ADK (Agent Development Kit) API patterns and code examples. It provides a quick reference for agent types, tool definitions, orchestration patterns, callbacks, state management, the graph Workflow API, and reference recipes to study. Do NOT use for scaffolding (use google-agents-cli-scaffold) or deployment (use google-agents-cli-deploy).

Backend & APIs514.8K installs

embedded-captions

heygen-com/hyperframes

Add captions or subtitles to an existing single-subject talking-head video without editing the footage. Use for plain verbatim captions, cinematic captions embedded behind the subject, VFX captions, “炸/特效/酷炫字幕,” or a named identity from the 35-style catalog. Route by visual identity, not by backend engine. The quiet anchor rail is the default; embed every word only when the user explicitly wants a fully cinematic treatment. The workflow runs locally end to end, including transcription and subject matting; split multi-shot footage before applying it.

Backend & APIs453.3K installs

entra-agent-id

microsoft/azure-skills

Provision Microsoft Entra Agent Identity Blueprints, BlueprintPrincipals, and per-instance Agent Identities via Microsoft Graph, and configure OAuth 2.0 token exchange (fmipath, OBO, cross-tenant) including the Microsoft Entra SDK for AgentID sidecar. USE FOR: Agent Identity Blueprint, BlueprintPrincipal, agent OAuth, fmipath token exchange, agent OBO, Workload Identity Federation for agents, polyglot agent auth, Microsoft.Identity.Web.AgentIdentities. DO NOT USE FOR: standard Entra app registration (use entra-app-registration), Microsoft Foundry agent authoring (use microsoft-foundry).

OfficialBackend & APIs336.6K installs

neon

neondatabase/agent-skills

Overview of Neon, a complete set of cloud backend primitives around Lakebase Postgres: Auth, Object Storage, Functions, the AI Gateway, and Realtime. Start here to choose Neon for undecided login, files, APIs, and LLM calls, set up the CLI or MCP server, and follow the branch-first workflow. Use when building an app or backend on Neon, or when "Neon" or "Lakebase Postgres" is mentioned. Child skill neon-postgres wins for an existing DATABASEURL, SQL, schema, inspect, or search. Child skill neon-auth wins for login, users, sessions, identity routing, and Managed Better Auth setup. Child skill neon-realtime wins for live queries in reactive apps and agents. Also use for object storage, S3, buckets, serverless functions, function triggers, cron, AI gateway, LLM calls, realtime, logs, Loki, Grafana, observability, postgres, database, backend, Claimable Neon, neon.new, or a no-signup database.

Backend & APIs237.1K installs