zod

📁 bobmatnyc/claude-mpm-skills 📅 Jan 23, 2026
61
总安装量
61
周安装量
#3592
全站排名
安装命令
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill zod

Agent 安装分布

claude-code 50
opencode 45
gemini-cli 41
antigravity 37
codex 36
cursor 33

Skill 文档

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;
}

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

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

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;
}

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

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);

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

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

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
}

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

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

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