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
Senior Developer

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 stringNow 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 productionCustom 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
productIdfield that arrives as an integer (from a client that ignored your docs) crashing your UUID-expecting queryAn empty array in
itemsthat creates a malformed SQLIN ()clauseA
priceof-99from a client testing your checkout flowA
redirect_urlofjavascript:alert(1)from someone probing your OAuth flowA 10MB JSON body sent to an endpoint expecting 200 bytes
A
roleofsuperadminsubmitted in a registration formNull 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.
Comments (0)
Login to post a comment.