References

Zod Validation

Runtime validation with compile-time type safety through automatic type inference. Best for TypeScript projects needing both validation and types from a single source.

Dependencies:

{
  "zod": ">=3.0.0 <4.0.0"
}

Core Patterns

✅ REQUIRED: Use z.infer for Type Extraction

// ✅ CORRECT: Schema as source of truth
import { z } from 'zod';

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive(),
});

type User = z.infer<typeof userSchema>;
// Auto-inferred: { name: string; email: string; age: number }

// ❌ WRONG: Manual type (can drift from schema)
interface User {
  name: string;
  email: string;
  age: number;
}
const userSchema = z.object({ /* ... */ });

✅ REQUIRED: Use safeParse for Error Handling

// ✅ CORRECT: Non-throwing validation
const result = userSchema.safeParse(data);

if (result.success) {
  const validated: User = result.data;
  // Process validated data
} else {
  console.error(result.error.format());
  // { name: { _errors: ['Required'] }, email: { _errors: ['Invalid'] } }
}

// ❌ WRONG: parse() throws exception
try {
  const data = userSchema.parse(input); // Throws ValidationError
} catch (error) {
  // Requires try/catch everywhere
}

✅ REQUIRED: Chain Validations

// ✅ CORRECT: Multiple constraints
const email = z
  .string()
  .email('Invalid email format')
  .min(5, 'Email too short')
  .max(100, 'Email too long')
  .toLowerCase(); // Transform to lowercase

const password = z
  .string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Must contain uppercase letter')
  .regex(/[0-9]/, 'Must contain number');

// ❌ WRONG: Single validation
const email = z.string(); // Too permissive

Custom Refinements

// Custom validation logic
const passwordSchema = z
  .string()
  .refine((val) => val.length >= 8, {
    message: 'Password must be at least 8 characters',
  })
  .refine((val) => /[A-Z]/.test(val), {
    message: 'Password must contain uppercase letter',
  });

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

Common Validations

String Validations

z.string(); // Any string
z.string().min(3); // Min length
z.string().max(20); // Max length
z.string().length(10); // Exact length
z.string().email(); // Valid email
z.string().url(); // Valid URL
z.string().uuid(); // UUID format
z.string().regex(/^\d{3}-\d{3}-\d{4}$/); // Phone pattern
z.string().startsWith('https://'); // Prefix check
z.string().endsWith('.com'); // Suffix check
z.string().trim(); // Trim whitespace
z.string().toLowerCase(); // Transform to lowercase
z.string().nonempty(); // Alias for .min(1)

Number Validations

z.number(); // Any number
z.number().int(); // Integer only
z.number().positive(); // > 0
z.number().nonnegative(); // >= 0
z.number().negative(); // < 0
z.number().min(18); // Minimum value
z.number().max(65); // Maximum value
z.number().multipleOf(5); // Divisible by 5
z.coerce.number(); // Parse string to number ("42" → 42)

Optional and Nullable

z.string().optional(); // string | undefined
z.string().nullable(); // string | null
z.string().nullish(); // string | null | undefined
z.string().default('N/A'); // Provides default if undefined

Arrays and Objects

// Array of items
const tagsSchema = z.array(z.string())
  .min(1, 'At least one tag required')
  .max(5, 'Maximum 5 tags')
  .nonempty(); // Alias for .min(1)

// Nested object
const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/),
});

const userSchema = z.object({
  name: z.string(),
  address: addressSchema, // Nested
});

Union and Enum

// Union types
const idSchema = z.union([z.string(), z.number()]);
// string | number

// Enum (literal values)
const statusSchema = z.enum(['pending', 'active', 'archived']);
// 'pending' | 'active' | 'archived'

// Discriminated union (better performance)
const eventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
  z.object({ type: z.literal('keypress'), key: z.string() }),
]);

Advanced Patterns

Transforms

// Transform during validation
const dateSchema = z.string().transform((str) => new Date(str));
// Input: "2024-01-01" → Output: Date object

const trimmedString = z.string().transform((s) => s.trim().toLowerCase());

// Chaining transform with validation
const ageSchema = z
  .string()
  .transform((val) => Number.parseInt(val, 10))
  .pipe(z.number().int().min(0).max(120));

Async Validation

const usernameSchema = z.string().refine(
  async (username) => {
    const available = await checkUsernameAvailable(username);
    return available;
  },
  { message: 'Username already taken' }
);

// Use parseAsync
const result = await usernameSchema.safeParseAsync('john_doe');

Recursive Schemas (Circular References)

interface Category {
  name: string;
  subcategories: Category[];
}

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

Partial and Pick

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

// All fields optional
const partialUser = userSchema.partial();
// { name?: string; email?: string; age?: number }

// Select specific fields
const userCredentials = userSchema.pick({ email: true, password: true });
// { email: string; password: string }

// Omit specific fields
const publicUser = userSchema.omit({ password: true });

Error Handling

Format Errors

const result = schema.safeParse(data);

if (!result.success) {
  // Formatted nested errors
  const formatted = result.error.format();
  // { email: { _errors: ['Invalid email'] }, age: { _errors: ['Too young'] } }

  // Flattened errors
  const flattened = result.error.flatten();
  // { formErrors: [], fieldErrors: { email: ['Invalid email'], age: ['Too young'] } }

  // Array of issues
  const issues = result.error.issues;
  // [{ code: 'invalid_string', path: ['email'], message: '...' }]
}

Custom Error Messages

const schema = z.object({
  email: z.string({ required_error: 'Email is required' })
    .email({ message: 'Invalid email format' }),

  age: z.number({ invalid_type_error: 'Age must be a number' })
    .min(18, { message: 'Must be 18 or older' }),
});

Integration Examples

React Hook Form + Zod

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

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

type FormData = z.infer<typeof schema>;

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

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

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

Express API Validation

import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';

const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    age: z.number().int().positive(),
  }),
});

function validateRequest(schema: z.ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse({ body: req.body });

    if (!result.success) {
      return res.status(400).json({ errors: result.error.format() });
    }

    next();
  };
}

app.post('/users', validateRequest(createUserSchema), (req, res) => {
  // req.body is validated and type-safe here
  const user = req.body;
});

Edge Cases

Coercion from strings: Use z.coerce.number() for form inputs that arrive as strings but need numbers.

const schema = z.object({
  age: z.coerce.number().int().min(18),
});
// "25" → 25 (number)

Unknown keys: By default, Zod strips unknown keys. Use .passthrough() to keep them or .strict() to throw error.

const strict = schema.strict(); // Throws on unknown keys
const passthrough = schema.passthrough(); // Keeps unknown keys

Discriminated unions: Use .discriminatedUnion() for better performance with large unions.

z.discriminatedUnion('type', [
  z.object({ type: z.literal('success'), data: z.any() }),
  z.object({ type: z.literal('error'), message: z.string() }),
]);


References