Back to Blog
ValidationSecurityBest PracticesData Quality

Data Validation Patterns for Robust Applications

Implement validation that keeps your application secure and reliable. From schema validation to runtime checks to error handling.

B
Bootspring Team
Engineering
May 20, 2025
6 min read

Data validation is your application's first line of defense. Invalid data causes bugs, security vulnerabilities, and corrupted databases. Robust validation catches problems early and provides clear feedback to users and developers.

Validation Principles#

Validate at Boundaries#

External input → Validation → Internal processing Boundaries: - API endpoints - Form submissions - File uploads - Database reads (legacy data) - Third-party API responses - Environment variables

Fail Fast#

1// ❌ Late validation - error deep in system 2function processOrder(order) { 3 // ... 50 lines of processing 4 if (!order.items.length) { 5 throw new Error('No items'); // Too late! 6 } 7} 8 9// ✅ Early validation - catch immediately 10function processOrder(order) { 11 validateOrder(order); // Throws if invalid 12 // ... safe processing 13}

Schema Validation with Zod#

Basic Schemas#

1import { z } from 'zod'; 2 3const userSchema = z.object({ 4 email: z.string().email(), 5 password: z.string().min(8).max(100), 6 name: z.string().min(1).max(255), 7 age: z.number().int().positive().optional(), 8}); 9 10type User = z.infer<typeof userSchema>; 11 12// Validation 13const result = userSchema.safeParse(input); 14if (!result.success) { 15 console.error(result.error.format()); 16} else { 17 const user: User = result.data; 18}

Complex Schemas#

1const orderSchema = z.object({ 2 customerId: z.string().uuid(), 3 4 items: z.array(z.object({ 5 productId: z.string().uuid(), 6 quantity: z.number().int().positive(), 7 price: z.number().positive(), 8 })).min(1), 9 10 shipping: z.discriminatedUnion('method', [ 11 z.object({ 12 method: z.literal('standard'), 13 address: addressSchema, 14 }), 15 z.object({ 16 method: z.literal('pickup'), 17 storeId: z.string(), 18 }), 19 ]), 20 21 couponCode: z.string().optional(), 22 23 metadata: z.record(z.string()).optional(), 24});

Transformations#

1const userInputSchema = z.object({ 2 email: z.string().email().toLowerCase(), 3 name: z.string().trim(), 4 birthDate: z.string().transform(str => new Date(str)), 5 tags: z.string().transform(str => str.split(',').map(s => s.trim())), 6});

Refinements#

1const passwordSchema = z.string() 2 .min(8) 3 .refine( 4 (password) => /[A-Z]/.test(password), 5 { message: 'Must contain uppercase letter' } 6 ) 7 .refine( 8 (password) => /[0-9]/.test(password), 9 { message: 'Must contain number' } 10 ) 11 .refine( 12 (password) => /[!@#$%^&*]/.test(password), 13 { message: 'Must contain special character' } 14 ); 15 16const registrationSchema = z.object({ 17 password: passwordSchema, 18 confirmPassword: z.string(), 19}).refine( 20 (data) => data.password === data.confirmPassword, 21 { message: 'Passwords must match', path: ['confirmPassword'] } 22);

API Validation#

Express Middleware#

1import { z } from 'zod'; 2import { Request, Response, NextFunction } from 'express'; 3 4function validate<T extends z.ZodSchema>(schema: T) { 5 return (req: Request, res: Response, next: NextFunction) => { 6 const result = schema.safeParse({ 7 body: req.body, 8 query: req.query, 9 params: req.params, 10 }); 11 12 if (!result.success) { 13 return res.status(400).json({ 14 error: 'Validation failed', 15 details: result.error.format(), 16 }); 17 } 18 19 req.validated = result.data; 20 next(); 21 }; 22} 23 24// Usage 25const createUserSchema = z.object({ 26 body: z.object({ 27 email: z.string().email(), 28 name: z.string().min(1), 29 }), 30}); 31 32app.post('/users', validate(createUserSchema), (req, res) => { 33 const { email, name } = req.validated.body; 34 // Guaranteed valid 35});

tRPC Integration#

1import { z } from 'zod'; 2import { router, publicProcedure } from './trpc'; 3 4const userRouter = router({ 5 create: publicProcedure 6 .input(z.object({ 7 email: z.string().email(), 8 name: z.string().min(1), 9 })) 10 .mutation(async ({ input }) => { 11 // input is typed and validated 12 return createUser(input); 13 }), 14 15 getById: publicProcedure 16 .input(z.string().uuid()) 17 .query(async ({ input: id }) => { 18 return getUserById(id); 19 }), 20});

Form Validation#

React Hook Form + Zod#

1import { useForm } from 'react-hook-form'; 2import { zodResolver } from '@hookform/resolvers/zod'; 3import { z } from 'zod'; 4 5const formSchema = z.object({ 6 email: z.string().email('Invalid email'), 7 password: z.string().min(8, 'Password must be at least 8 characters'), 8 acceptTerms: z.literal(true, { 9 errorMap: () => ({ message: 'You must accept the terms' }), 10 }), 11}); 12 13type FormData = z.infer<typeof formSchema>; 14 15function SignupForm() { 16 const { 17 register, 18 handleSubmit, 19 formState: { errors }, 20 } = useForm<FormData>({ 21 resolver: zodResolver(formSchema), 22 }); 23 24 return ( 25 <form onSubmit={handleSubmit(onSubmit)}> 26 <input {...register('email')} /> 27 {errors.email && <span>{errors.email.message}</span>} 28 29 <input type="password" {...register('password')} /> 30 {errors.password && <span>{errors.password.message}</span>} 31 32 <input type="checkbox" {...register('acceptTerms')} /> 33 {errors.acceptTerms && <span>{errors.acceptTerms.message}</span>} 34 35 <button type="submit">Sign Up</button> 36 </form> 37 ); 38}

Database Validation#

Prisma Integration#

1// Validate before database operations 2const createProductSchema = z.object({ 3 name: z.string().min(1).max(255), 4 price: z.number().positive(), 5 categoryId: z.string().uuid(), 6}); 7 8async function createProduct(input: unknown) { 9 const validated = createProductSchema.parse(input); 10 11 return prisma.product.create({ 12 data: validated, 13 }); 14}

Database Constraints as Backup#

1-- Database-level validation as last defense 2CREATE TABLE users ( 3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 4 email VARCHAR(255) NOT NULL UNIQUE, 5 name VARCHAR(255) NOT NULL, 6 age INTEGER CHECK (age > 0), 7 created_at TIMESTAMP NOT NULL DEFAULT NOW(), 8 9 CONSTRAINT email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') 10);

Environment Validation#

At Startup#

1const envSchema = z.object({ 2 NODE_ENV: z.enum(['development', 'production', 'test']), 3 DATABASE_URL: z.string().url(), 4 JWT_SECRET: z.string().min(32), 5 PORT: z.string().regex(/^\d+$/).transform(Number).default('3000'), 6 REDIS_URL: z.string().url().optional(), 7}); 8 9type Env = z.infer<typeof envSchema>; 10 11function validateEnv(): Env { 12 const result = envSchema.safeParse(process.env); 13 14 if (!result.success) { 15 console.error('Invalid environment variables:'); 16 console.error(result.error.format()); 17 process.exit(1); 18 } 19 20 return result.data; 21} 22 23export const env = validateEnv();

Error Messages#

User-Friendly Messages#

1const userSchema = z.object({ 2 email: z.string({ 3 required_error: 'Email is required', 4 invalid_type_error: 'Email must be a string', 5 }).email('Please enter a valid email address'), 6 7 password: z.string() 8 .min(8, 'Password must be at least 8 characters') 9 .max(100, 'Password must be less than 100 characters'), 10 11 age: z.number({ 12 required_error: 'Age is required', 13 invalid_type_error: 'Age must be a number', 14 }).int('Age must be a whole number') 15 .positive('Age must be positive'), 16});

Error Formatting#

1function formatValidationErrors(error: z.ZodError) { 2 return error.errors.map(err => ({ 3 field: err.path.join('.'), 4 message: err.message, 5 })); 6} 7 8// Output: 9// [ 10// { field: 'email', message: 'Invalid email' }, 11// { field: 'password', message: 'Password too short' } 12// ]

Sanitization#

Input Sanitization#

1import DOMPurify from 'dompurify'; 2 3const commentSchema = z.object({ 4 content: z.string() 5 .min(1) 6 .max(10000) 7 .transform(str => DOMPurify.sanitize(str)), 8}); 9 10const userInputSchema = z.object({ 11 name: z.string() 12 .trim() 13 .replace(/[<>]/g, ''), // Remove angle brackets 14 15 url: z.string() 16 .url() 17 .refine( 18 url => url.startsWith('https://'), 19 'URL must use HTTPS' 20 ), 21});

Testing Validation#

1describe('userSchema', () => { 2 it('accepts valid input', () => { 3 const input = { email: 'test@example.com', name: 'John' }; 4 expect(() => userSchema.parse(input)).not.toThrow(); 5 }); 6 7 it('rejects invalid email', () => { 8 const input = { email: 'not-an-email', name: 'John' }; 9 const result = userSchema.safeParse(input); 10 expect(result.success).toBe(false); 11 expect(result.error?.issues[0].path).toContain('email'); 12 }); 13 14 it('rejects empty name', () => { 15 const input = { email: 'test@example.com', name: '' }; 16 const result = userSchema.safeParse(input); 17 expect(result.success).toBe(false); 18 }); 19 20 it('transforms email to lowercase', () => { 21 const input = { email: 'TEST@EXAMPLE.COM', name: 'John' }; 22 const result = userSchema.parse(input); 23 expect(result.email).toBe('test@example.com'); 24 }); 25});

Best Practices#

1. Single Source of Truth#

1// Define once, use everywhere 2export const userSchema = z.object({...}); 3export type User = z.infer<typeof userSchema>; 4 5// API uses it 6app.post('/users', validate(userSchema), handler); 7 8// Form uses it 9const form = useForm({ resolver: zodResolver(userSchema) }); 10 11// Tests use it 12const validUser = generateValid(userSchema);

2. Composition#

1const addressSchema = z.object({ 2 street: z.string(), 3 city: z.string(), 4 zipCode: z.string(), 5}); 6 7const userSchema = z.object({ 8 email: z.string().email(), 9 address: addressSchema, 10 billingAddress: addressSchema.optional(), 11});

3. Graceful Degradation#

1const configSchema = z.object({ 2 apiUrl: z.string().url(), 3 timeout: z.number().default(5000), 4 retries: z.number().default(3), 5 features: z.object({ 6 darkMode: z.boolean().default(false), 7 analytics: z.boolean().default(true), 8 }).default({}), 9}); 10 11// Works with partial input 12const config = configSchema.parse({ apiUrl: 'https://api.example.com' });

Conclusion#

Validation is not optional—it's essential for security, reliability, and user experience. Use schema validation libraries like Zod for type-safe, composable validation that works across your entire stack.

Validate at every boundary, fail fast with clear errors, and test your validation logic thoroughly. The effort invested in robust validation pays dividends in prevented bugs and security issues.

Share this article

Help spread the word about Bootspring