{"schemaVersion":"1.0","type":"Article","types":["Article"],"slug":"input-validation-with-zod-stop-trusting-your-users-and-your-own-code-7hsth","url":"https://zyvop.com/input-validation-with-zod-stop-trusting-your-users-and-your-own-code-7hsth","title":"Input Validation with Zod: Stop Trusting Your Users (and Your Own Code)","subtitle":"Transforms, middleware factories, environment validation, and the production bugs that a proper schema prevents before they happen","tldr":"Every security breach that starts with \"attacker submitted unexpected input\" is a validation failure. Every production bug that starts with \"we assumed this fie...","keywords":["Zod middleware","Zod TypeScript 2026","Zod environment variables","Zod transform","input validation Express","Zod request validation","TypeScript validation library","Zod validation Node.js"],"entities":["ZyVOP","Zod middleware","Zod TypeScript 2026","Zod environment variables","Zod transform","input validation Express","Zod request validation","TypeScript validation library","Zod validation Node.js"],"keyTakeaways":["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."],"headings":["Why Zod Over Everything Else","Setup","Building Schemas That Actually Cover Your Cases","Strings with Real Constraints","Numbers With Business Logic","Dates","Enums and Unions","Nested and Composed Schemas","The Transform Pattern","Request Validation Middleware","Environment Variable Validation","Custom Error Messages That Actually Help","Reusable Schema Utilities","What Good Validation Catches"],"outboundLinks":[],"contentText":"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&lt;typeof UserSchema&gt;; // { 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 =&gt; !s.startsWith('-') &amp;&amp; !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) =&gt; data.endDate &gt; 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&lt;typeof CreateOrderSchema&gt;;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) =&gt; ({ // 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&lt;typeof CreateUserSchema&gt;; // { 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&lt;T extends ZodSchema&gt;( schema: T, target: ValidateTarget = 'body' ) { return (req, res, next) =&gt; { 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) =&gt; { // 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) =&gt; { // 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 =&gt; val === true, { message: 'You must agree to the terms of service to continue', }), }).refine( (data) =&gt; 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) =&gt; !data.from || !data.to || data.to &gt;= 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&lt;T extends z.ZodRawShape&gt;(shape: T) { return z.object(shape).partial().refine( (data) =&gt; Object.keys(data).length &gt; 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.","contentHash":"sha256:217ae77b8d05947c25f13d513ee7c72a0a4d30806af6fdc8b7ae6e65f96aaf89","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["Zod middleware","Zod TypeScript 2026","Zod environment variables","Zod transform","input validation Express","Zod request validation","TypeScript validation library","Zod validation Node.js"],"audience":"Software engineers and developers building applications with Zod middleware","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":7,"wordCount":1612,"faqs":null,"primaryTopic":"Zod middleware","publishedAt":"2026-05-26T06:06:35.322Z","updatedAt":"2026-08-23T17:00:00.305Z","canonicalUrl":"https://zyvop.com/input-validation-with-zod-stop-trusting-your-users-and-your-own-code-7hsth"}