ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeInput Validation with Zod: Stop Trusting Your Users (and Your Own Code)

Input Validation with Zod: Stop Trusting Your Users (and Your Own Code)

Transforms, middleware factories, environment validation, and the production bugs that a proper schema prevents before they happen

#Zod validation Node.js#Zod TypeScript 2026#input validation Express#Zod middleware#Zod environment variables#Zod transform#Zod request validation#TypeScript validation library
Z
ZyVOP

Senior Developer

May 26, 2026
8 min read
12 views
Input Validation with Zod: Stop Trusting Your Users (and Your Own Code)

Every security breach that starts with "attacker submitted unexpected input" is a validation failure. Every production bug that starts with "we assumed this field would always be a string" is a validation failure. Every 500 error that says "cannot read property of undefined" on data that came from an API call is a validation failure.

Validation is not a nice-to-have. It is the contract between your code and the world outside it — and Zod is the best tool the Node.js ecosystem has for writing that contract in TypeScript.

This guide goes beyond the basic z.string() examples. It covers the patterns you actually need in production: nested schemas, custom error messages, request validation middleware, environment variable validation, and the transform pattern that converts external data into internal types.


Why Zod Over Everything Else

You have options: Joi, Yup, class-validator, manual if-statements. Zod's advantage is that it is TypeScript-first in a way none of the others are. When you define a Zod schema, TypeScript infers the type from it. You do not write a type and a schema separately and hope they stay in sync. The schema is the type.

import { z } from 'zod';

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

// Type is inferred — no manual interface needed
type User = z.infer<typeof UserSchema>;
// { email: string; age: number; role: "user" | "admin" }

One source of truth. Schema changes automatically update the type.


Setup

npm install zod

Building Schemas That Actually Cover Your Cases

Strings with Real Constraints

const emailSchema = z
  .string()
  .trim()                         // Strip whitespace before validating
  .toLowerCase()                  // Normalize to lowercase
  .email('Must be a valid email address')
  .max(254, 'Email too long');    // RFC 5321 max length

const passwordSchema = z
  .string()
  .min(10, 'Password must be at least 10 characters')
  .max(128, 'Password too long')
  .regex(/[A-Z]/, 'Must contain at least one uppercase letter')
  .regex(/[0-9]/, 'Must contain at least one number')
  .regex(/[^A-Za-z0-9]/, 'Must contain at least one special character');

const slugSchema = z
  .string()
  .min(3)
  .max(50)
  .regex(/^[a-z0-9-]+$/, 'Only lowercase letters, numbers, and hyphens allowed')
  .refine(s => !s.startsWith('-') && !s.endsWith('-'), {
    message: 'Cannot start or end with a hyphen',
  });

// UUID validation
const uuidSchema = z.string().uuid('Invalid ID format');

Numbers With Business Logic

const priceSchema = z
  .number()
  .positive('Price must be positive')
  .multipleOf(0.01, 'Price must have at most 2 decimal places')
  .max(999999.99, 'Price exceeds maximum');

const quantitySchema = z
  .number()
  .int('Quantity must be a whole number')
  .min(1, 'Quantity must be at least 1')
  .max(1000, 'Quantity cannot exceed 1000');

// Coerce string to number — useful for query params which arrive as strings
const pageSchema = z.coerce.number().int().min(1).default(1);
const limitSchema = z.coerce.number().int().min(1).max(100).default(20);

Dates

// Accept ISO strings from JSON bodies, coerce to Date
const dateSchema = z.coerce.date();

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

Enums and Unions

const statusSchema = z.enum(['active', 'inactive', 'pending']);

// Union of different shapes (discriminated union is more precise)
const eventSchema = z.discriminatedUnion('type', [
  z.object({
    type:    z.literal('email'),
    address: z.string().email(),
  }),
  z.object({
    type:  z.literal('sms'),
    phone: z.string().regex(/^\+[1-9]\d{1,14}$/),
  }),
  z.object({
    type:    z.literal('webhook'),
    url:     z.string().url(),
    headers: z.record(z.string()).optional(),
  }),
]);

Nested and Composed Schemas

const AddressSchema = z.object({
  line1:    z.string().min(1).max(100),
  line2:    z.string().max(100).optional(),
  city:     z.string().min(1).max(100),
  state:    z.string().length(2, 'State must be 2-letter code'),
  postcode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid postcode'),
  country:  z.string().length(2, 'Country must be ISO 2-letter code'),
});

const CreateOrderSchema = z.object({
  items: z.array(
    z.object({
      productId: uuidSchema,
      quantity:  quantitySchema,
    })
  ).min(1, 'Order must have at least one item')
   .max(50, 'Order cannot have more than 50 items'),

  shippingAddress: AddressSchema,

  couponCode: z.string().toUpperCase().optional(),

  notes: z.string().max(500).optional(),
});

type CreateOrder = z.infer<typeof CreateOrderSchema>;

The Transform Pattern

Transforms convert raw input into the internal representation your code needs. This is the pattern that replaces hand-written data normalization everywhere in your codebase.

// Raw input: snake_case strings from a form
// Internal type: camelCase, proper types

const CreateUserSchema = z.object({
  email:      z.string().trim().toLowerCase().email(),
  full_name:  z.string().trim().min(1).max(100),
  birth_date: z.string().date(),    // "YYYY-MM-DD"
  phone:      z.string().optional(),
}).transform((data) => ({
  // Convert to internal representation in one step
  email:     data.email,
  fullName:  data.full_name,
  birthDate: new Date(data.birth_date),
  phone:     data.phone?.replace(/\D/g, '') || null,  // Strip non-digits
}));

// The output type reflects the transform
type CreateUserInput = z.output<typeof CreateUserSchema>;
// { email: string; fullName: string; birthDate: Date; phone: string | null }

Request Validation Middleware

A reusable middleware factory that validates request body, query params, or URL params:

// src/middleware/validate.ts
import { z, ZodSchema } from 'zod';

type ValidateTarget = 'body' | 'query' | 'params';

export function validate<T extends ZodSchema>(
  schema: T,
  target: ValidateTarget = 'body'
) {
  return (req, res, next) => {
    const result = schema.safeParse(req[target]);

    if (!result.success) {
      const errors = result.error.flatten();
      return res.status(400).json({
        error:   'Validation failed',
        details: errors.fieldErrors,   // Field-level errors
        form:    errors.formErrors,    // Non-field errors
      });
    }

    // Replace raw input with validated and transformed data
    req[target] = result.data;
    next();
  };
}

Usage:

import { validate } from '../middleware/validate';

// Body validation
router.post('/orders',
  authenticate,
  validate(CreateOrderSchema),
  async (req, res) => {
    // req.body is now fully typed as CreateOrder
    const order = await createOrder(req.tenant.id, req.user.id, req.body);
    res.status(201).json(order);
  }
);

// Query param validation
const ListOrdersQuerySchema = z.object({
  page:   z.coerce.number().int().min(1).default(1),
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  status: z.enum(['pending', 'paid', 'shipped', 'cancelled']).optional(),
  from:   z.coerce.date().optional(),
  to:     z.coerce.date().optional(),
});

router.get('/orders',
  authenticate,
  validate(ListOrdersQuerySchema, 'query'),
  async (req, res) => {
    // req.query.page is now a number, not a string
    const orders = await listOrders(req.tenant.id, req.query);
    res.json(orders);
  }
);

Environment Variable Validation

This is the most underused Zod pattern. Validate your environment variables at startup so the app fails immediately with a clear error instead of crashing mysteriously at runtime when a missing variable is first accessed.

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

const EnvSchema = z.object({
  // Server
  NODE_ENV:    z.enum(['development', 'test', 'production']),
  PORT:        z.coerce.number().int().min(1).max(65535).default(3000),
  APP_VERSION: z.string().default('unknown'),

  // Database
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),

  // Redis
  REDIS_HOST:     z.string().default('localhost'),
  REDIS_PORT:     z.coerce.number().int().default(6379),
  REDIS_PASSWORD: z.string().optional(),

  // Auth
  JWT_PRIVATE_KEY: z.string().min(1, 'JWT_PRIVATE_KEY is required'),
  JWT_PUBLIC_KEY:  z.string().min(1, 'JWT_PUBLIC_KEY is required'),

  // AWS
  AWS_REGION:            z.string().default('us-east-1'),
  AWS_ACCESS_KEY_ID:     z.string().optional(),
  AWS_SECRET_ACCESS_KEY: z.string().optional(),
  S3_BUCKET:             z.string().optional(),

  // External services
  STRIPE_SECRET_KEY:    z.string().optional(),
  SENTRY_DSN:           z.string().url().optional(),
});

// Validate at module load time — crashes immediately if invalid
const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;
// Every property is now typed correctly — no more process.env.PORT as string

Now everywhere in your codebase:

import { env } from '../lib/env';

// env.PORT is a number, not string | undefined
// env.DATABASE_URL is guaranteed to be a valid URL
// Missing required variables crash at startup, not in production

Custom Error Messages That Actually Help

Default Zod errors are functional but terse. For user-facing APIs, write messages a human can act on:

const RegistrationSchema = z.object({
  email: z
    .string({ required_error: 'Email is required' })
    .email('That doesn't look like a valid email address'),

  password: z
    .string({ required_error: 'Password is required' })
    .min(10, 'Password must be at least 10 characters long'),

  confirmPassword: z.string(),

  agreedToTerms: z
    .boolean()
    .refine(val => val === true, {
      message: 'You must agree to the terms of service to continue',
    }),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'],   // Error appears on the confirmPassword field
  }
);

Reusable Schema Utilities

// src/validators/common.ts — shared across your whole app

export const PaginationSchema = z.object({
  page:  z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

export const IdParamSchema = z.object({
  id: z.string().uuid('Invalid ID'),
});

export const DateRangeSchema = z.object({
  from: z.coerce.date().optional(),
  to:   z.coerce.date().optional(),
}).refine(
  (data) => !data.from || !data.to || data.to >= data.from,
  { message: 'End date must be after start date', path: ['to'] }
);

// Partial updates — all fields optional but at least one required
export function makeUpdateSchema<T extends z.ZodRawShape>(shape: T) {
  return z.object(shape).partial().refine(
    (data) => Object.keys(data).length > 0,
    { message: 'At least one field must be provided for update' }
  );
}

// Usage:
const UpdateUserSchema = makeUpdateSchema({
  fullName: z.string().min(1).max(100),
  email:    z.string().email(),
  role:     z.enum(['user', 'admin']),
});

What Good Validation Catches

The things validation prevents that developers often do not think about until they happen in production:

  • A productId field that arrives as an integer (from a client that ignored your docs) crashing your UUID-expecting query

  • An empty array in items that creates a malformed SQL IN () clause

  • A price of -99 from a client testing your checkout flow

  • A redirect_url of javascript:alert(1) from someone probing your OAuth flow

  • A 10MB JSON body sent to an endpoint expecting 200 bytes

  • A role of superadmin submitted in a registration form

  • Null bytes in strings that break certain database drivers

Every one of these is a validation failure. Every one of them is preventable with a schema that covers the actual constraints of your domain, not just the happy path.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design