How to Validate Input with Zod

Share
How-To Guide

How to Validate Input with Zod

TypeScript-first schema validation for secure, type-safe applications

TL;DR

TL;DR (20 minutes)

Zod is a TypeScript-first schema validation library. Define schemas once, use them for both validation and type inference. Use safeParse() for controlled error handling, refine() for custom validation logic, and transform() to normalize data. Integrate with React Hook Form for seamless form validation.

Prerequisites

  • Node.js 18+ installed
  • TypeScript project (Zod works with JS too, but you'll miss the best features)
  • Basic understanding of TypeScript types
  • npm or yarn package manager

Why Zod?

Zod provides runtime validation that integrates seamlessly with TypeScript's type system. Unlike other validation libraries, you define your schema once and get both runtime validation AND compile-time types automatically.

Zod vs. Other Libraries: Unlike Joi or Yup, Zod is TypeScript-first. You don't need to maintain separate type definitions - they're inferred from your schema. This eliminates the common bug of types not matching validation rules.

Step-by-Step Guide

1

Install Zod

Add Zod to your project:

npm install zod

That's it - Zod has zero dependencies and includes TypeScript types out of the box.

2

Define basic schemas

Start with primitive types and build up to complex objects:

import { z } from 'zod';

// Primitive types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();

// With constraints
const emailSchema = z.string().email('Invalid email format');
const ageSchema = z.number().int().min(0).max(120);
const usernameSchema = z.string()
  .min(3, 'Username must be at least 3 characters')
  .max(20, 'Username must be 20 characters or less')
  .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores');

// Object schemas
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1).max(100),
  age: z.number().int().positive().optional(),
  role: z.enum(['user', 'admin', 'moderator']),
  createdAt: z.date(),
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Result: { id: string; email: string; name: string; age?: number; role: 'user' | 'admin' | 'moderator'; createdAt: Date; }

// Array schemas
const TagsSchema = z.array(z.string()).min(1).max(10);
const UsersSchema = z.array(UserSchema);
3

Validate data with safeParse

Use safeParse() for controlled error handling (recommended for APIs):

import { z } from 'zod';

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

// Using safeParse (recommended - doesn't throw)
function validateLogin(data: unknown) {
  const result = LoginSchema.safeParse(data);

  if (!result.success) {
    // result.error contains structured error info
    console.log(result.error.flatten());
    // {
    //   formErrors: [],
    //   fieldErrors: {
    //     email: ['Invalid email'],
    //     password: ['String must contain at least 8 character(s)']
    //   }
    // }
    return { valid: false, errors: result.error.flatten().fieldErrors };
  }

  // result.data is typed as { email: string; password: string }
  return { valid: true, data: result.data };
}

// Using parse (throws on error - use in try/catch)
function validateLoginThrowing(data: unknown) {
  try {
    const validated = LoginSchema.parse(data);
    return validated; // Typed as { email: string; password: string }
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error(error.issues);
    }
    throw error;
  }
}
4

Use in API routes

Validate request bodies in your API handlers:

// lib/schemas/api.ts
import { z } from 'zod';

export const CreatePostSchema = z.object({
  title: z.string()
    .min(1, 'Title is required')
    .max(200, 'Title must be 200 characters or less'),
  content: z.string()
    .min(10, 'Content must be at least 10 characters')
    .max(50000, 'Content is too long'),
  tags: z.array(z.string().min(1).max(30))
    .min(1, 'At least one tag is required')
    .max(5, 'Maximum 5 tags allowed'),
  published: z.boolean().default(false),
});

export type CreatePostInput = z.infer<typeof CreatePostSchema>;

// app/api/posts/route.ts
import { CreatePostSchema } from '@/lib/schemas/api';
import { getServerSession } from 'next-auth';

export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const body = await request.json();

  // Validate request body
  const result = CreatePostSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      {
        error: 'Validation failed',
        details: result.error.flatten(),
      },
      { status: 400 }
    );
  }

  // result.data is fully typed and validated
  const post = await db.post.create({
    data: {
      ...result.data,
      authorId: session.user.id,
    },
  });

  return Response.json(post, { status: 201 });
}
5

Add custom validation with refine

Use refine() for custom validation logic:

import { z } from 'zod';

// Single field refinement
const PasswordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .refine(
    (password) => /[A-Z]/.test(password),
    { message: 'Password must contain at least one uppercase letter' }
  )
  .refine(
    (password) => /[a-z]/.test(password),
    { message: 'Password must contain at least one lowercase letter' }
  )
  .refine(
    (password) => /[0-9]/.test(password),
    { message: 'Password must contain at least one number' }
  )
  .refine(
    (password) => /[!@#$%^&*]/.test(password),
    { message: 'Password must contain at least one special character (!@#$%^&*)' }
  );

// Cross-field validation with superRefine
const SignupSchema = z.object({
  email: z.string().email(),
  password: PasswordSchema,
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'], // Error appears on confirmPassword field
  }
);

// Async validation (e.g., checking if email exists)
const UniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const existingUser = await db.user.findUnique({ where: { email } });
    return !existingUser;
  },
  { message: 'Email is already registered' }
);

// Use parseAsync for schemas with async refinements
const result = await UniqueEmailSchema.safeParseAsync('test@example.com');

// Date range validation
const DateRangeSchema = z.object({
  startDate: z.coerce.date(),
  endDate: z.coerce.date(),
}).refine(
  (data) => data.endDate > data.startDate,
  {
    message: 'End date must be after start date',
    path: ['endDate'],
  }
);
6

Transform and normalize data

Use transform() to clean and normalize input:

import { z } from 'zod';

// Normalize email to lowercase
const EmailSchema = z.string()
  .email()
  .transform((email) => email.toLowerCase().trim());

// Parse string to number
const QueryParamSchema = z.object({
  page: z.string().transform(Number).pipe(z.number().int().positive()),
  limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)),
});

// Or use coerce for automatic type coercion
const CoercedQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

// Clean and normalize user input
const UserInputSchema = z.object({
  name: z.string()
    .transform((s) => s.trim())
    .pipe(z.string().min(1).max(100)),

  bio: z.string()
    .transform((s) => s.trim())
    .transform((s) => s.replace(/\s+/g, ' ')) // Collapse whitespace
    .pipe(z.string().max(500))
    .optional(),

  website: z.string()
    .transform((s) => {
      s = s.trim();
      if (s && !s.startsWith('http')) {
        return `https://${s}`;
      }
      return s;
    })
    .pipe(z.string().url().optional().or(z.literal(''))),
});

// Preprocess for handling null/undefined
const OptionalStringSchema = z.preprocess(
  (val) => (val === '' ? undefined : val),
  z.string().min(1).optional()
);
7

Integrate with React Hook Form

Use Zod with React Hook Form for type-safe form validation:

// Install the resolver
// npm install @hookform/resolvers

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

const ContactSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Invalid email'),
  message: z.string().min(10, 'Message must be at least 10 characters').max(1000),
});

type ContactForm = z.infer<typeof ContactSchema>;

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<ContactForm>({
    resolver: zodResolver(ContactSchema),
    defaultValues: {
      name: '',
      email: '',
      message: '',
    },
  });

  const onSubmit = async (data: ContactForm) => {
    // data is fully typed and validated
    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    if (response.ok) {
      reset();
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register('name')} />
        {errors.name && <span className="error">{errors.name.message}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" {...register('message')} />
        {errors.message && <span className="error">{errors.message.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}
8

Build reusable schema patterns

Create reusable schemas for common patterns:

// lib/schemas/common.ts
import { z } from 'zod';

// Reusable field schemas
export const emailField = z.string().email().max(254).toLowerCase();

export const passwordField = z.string()
  .min(8, 'Password must be at least 8 characters')
  .max(100, 'Password is too long')
  .regex(/[A-Z]/, 'Must contain uppercase letter')
  .regex(/[a-z]/, 'Must contain lowercase letter')
  .regex(/[0-9]/, 'Must contain number');

export const uuidField = z.string().uuid();

export const slugField = z.string()
  .min(1)
  .max(100)
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid slug format');

export const paginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).default('desc'),
});

// API response wrapper
export function createApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
  return z.object({
    success: z.literal(true),
    data: dataSchema,
    meta: z.object({
      timestamp: z.string().datetime(),
      requestId: z.string(),
    }).optional(),
  });
}

// Paginated response
export function createPaginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
  return z.object({
    items: z.array(itemSchema),
    pagination: z.object({
      page: z.number(),
      limit: z.number(),
      total: z.number(),
      totalPages: z.number(),
    }),
  });
}

// Usage
const UserSchema = z.object({
  id: uuidField,
  email: emailField,
  name: z.string().min(1).max(100),
});

const UsersResponseSchema = createPaginatedSchema(UserSchema);
type UsersResponse = z.infer<typeof UsersResponseSchema>;

Security Checklist

  • Always use server-side validation (client-side can be bypassed)
  • Use safeParse() instead of parse() in APIs for controlled error handling
  • Set maximum lengths on all string fields to prevent DoS
  • Use strict() to reject extra fields: z.object({...}).strict()
  • Validate enum values to prevent invalid states
  • Use coerce carefully - it may accept unexpected input types
  • Combine with sanitization for HTML/user content
  • Don't expose detailed validation errors for sensitive fields

How to Verify It Worked

Test your Zod validation:

import { z } from 'zod';

// Test your schemas
const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().positive(),
});

// Valid data
const valid = UserSchema.safeParse({ email: 'test@example.com', age: 25 });
console.assert(valid.success === true, 'Valid data should pass');

// Invalid email
const invalidEmail = UserSchema.safeParse({ email: 'not-an-email', age: 25 });
console.assert(invalidEmail.success === false, 'Invalid email should fail');
console.assert(
  invalidEmail.error?.flatten().fieldErrors.email?.length > 0,
  'Should have email error'
);

// Wrong type
const wrongType = UserSchema.safeParse({ email: 'test@example.com', age: '25' });
console.assert(wrongType.success === false, 'String age should fail');

// Extra fields (use strict() to reject)
const StrictSchema = UserSchema.strict();
const extraFields = StrictSchema.safeParse({
  email: 'test@example.com',
  age: 25,
  extra: 'field',
});
console.assert(extraFields.success === false, 'Extra fields should be rejected');

// Test API endpoint
const testApiValidation = async () => {
  // Missing required field
  const response1 = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify({ email: 'test@example.com' }), // Missing age
  });
  console.assert(response1.status === 400, 'Should return 400 for missing fields');

  // Valid request
  const response2 = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify({ email: 'test@example.com', age: 25 }),
  });
  console.assert(response2.status === 201, 'Should return 201 for valid data');
};

console.log('All validation tests passed!');

Pro Tip: Use Zod's .describe() method to add documentation to schemas. Tools like zod-to-openapi can generate OpenAPI specs from your Zod schemas automatically.

Common Errors and Troubleshooting

Error: Type inference not working

// Problem: Using `any` or not inferring type properly
const schema = z.object({ name: z.string() });
const data: any = schema.parse(input); // Lost type safety!

// Solution: Use z.infer
type MyType = z.infer<typeof schema>;
const data: MyType = schema.parse(input);
// Or let TypeScript infer:
const data = schema.parse(input); // Automatically typed

Error: Async validation not working

// Problem: Using safeParse with async refinements
const schema = z.string().refine(async (val) => checkDatabase(val));
const result = schema.safeParse(input); // Won't work!

// Solution: Use safeParseAsync for schemas with async refinements
const result = await schema.safeParseAsync(input);

Error: Transform changes the inferred type unexpectedly

// Problem: Transform changes output type
const schema = z.string().transform(Number);
// Input type: string, Output type: number

// If you want to validate the transformed value:
const schema = z.string()
  .transform(Number)
  .pipe(z.number().positive()); // Validate the number

// Or use coerce for simple type coercion:
const schema = z.coerce.number().positive();

Error: Optional vs nullable vs nullish

// These all behave differently:

// optional() - field can be missing OR undefined
z.string().optional(); // string | undefined

// nullable() - field can be null
z.string().nullable(); // string | null

// nullish() - field can be missing, undefined, OR null
z.string().nullish(); // string | null | undefined

// default() - provides a fallback value
z.string().default(''); // Always returns string

// Example of common confusion:
const schema = z.object({
  name: z.string().optional(), // Can be omitted
});

schema.parse({}); // OK: { name: undefined }
schema.parse({ name: null }); // ERROR: name must be string
schema.parse({ name: 'Alice' }); // OK: { name: 'Alice' }

Error: Discriminated union type errors

// Problem: Union errors are confusing
const schema = z.union([
  z.object({ type: z.literal('a'), value: z.string() }),
  z.object({ type: z.literal('b'), value: z.number() }),
]);

// Solution: Use discriminatedUnion for better errors
const schema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('a'), value: z.string() }),
  z.object({ type: z.literal('b'), value: z.number() }),
]);
// Now errors clearly indicate which variant failed based on 'type'

Frequently Asked Questions

Should I use parse() or safeParse()?

Use safeParse() in APIs and anywhere you need to handle errors gracefully. Use parse() when you want to throw on invalid data (e.g., internal code where invalid data indicates a bug). In production APIs, always use safeParse() to return proper error responses.

How do I validate environment variables with Zod?

Create an env schema and parse process.env at startup. This catches missing variables immediately:

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.coerce.number().default(3000),
});
export const env = envSchema.parse(process.env);

Can I use Zod for API response validation?

Yes! Validating API responses protects against unexpected data from third-party APIs:

const response = await fetch('...');
const data = ResponseSchema.parse(await response.json());

How do I handle recursive schemas?

Use z.lazy() for recursive types:

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

Is Zod fast enough for production?

Yes. Zod is optimized for performance. For extremely high-throughput scenarios, consider caching parsed schemas or using z.preprocess() to skip validation for trusted internal data.

How-To Guides

How to Validate Input with Zod