Skip to main content

Validation ๐Ÿ›ก๏ธ

Input validation is the first line of defense in any backend application. It ensures that data entering your system is well-formed, safe, and semantically correct before it reaches your business logic, database, or any downstream service.

Never trust user input. Validate everything. Sanitize what you can't reject.


Why Validation Mattersโ€‹

Skipping validation is like leaving your front door unlocked. Attackers constantly probe endpoints for injection vectors, malformed payloads, and edge cases. Even non-malicious clients send bad data โ€” empty fields, wrong types, strings where numbers belong.

RiskWithout ValidationWith Validation
SQL InjectionRaw user input concatenated into queriesRejected before touching the database
XSSStored malicious scripts rendered to usersStripped or escaped on input
Data corruptionnull in NOT NULL columns, wrong typesSchema enforcement catches mismatches
Business logic bugsNegative prices, future birth datesCustom rules reject invalid states
API contract violationsDownstream services receive garbageContract enforced at the boundary
Debugging hellError discovered deep in the stackError caught at the boundary with clear message

Core principle: Fail fast and fail loudly. Catch bad data as early as possible, and return actionable error messages to the caller.


Defense in Depth: Where to Validateโ€‹

Relying on a single validation layer is fragile. A robust system validates at multiple boundaries.

graph LR
A[Client] -->|1. Client-side validation| B[API Gateway / Load Balancer]
B -->|2. Gateway validation| C[Application Server]
C -->|3. Server-side validation| D[Business Logic]
D -->|4. Business rules| E[Database]
E -->|5. Schema constraints| F[(Data Store)]

style A fill:#e1f5fe,stroke:#01579b
style C fill:#fff3e0,stroke:#e65100
style E fill:#e8f5e9,stroke:#1b5e20
LayerResponsibilityTools / TechniquesMust-Have?
ClientImmediate UX feedback, reduce round-tripsHTML5 validation, Zod (shared schemas)โŒ Nice-to-have (never trust alone)
Gateway / ProxyBasic request validation, rate limitingWAF rules, NGINX/Envoy Lua scripting, AWS WAFโœ… For public-facing APIs
Application (Server)Primary validation layer โ€” schema, types, business rulesJoi, Zod, class-validator, custom middlewareโœ… Mandatory
Business LogicDomain invariants, cross-field rulesService-layer checks, domain-driven designโœ… For complex domains
DatabaseLast line of defense โ€” type constraints, NOT NULL, CHECKSQL constraints, triggers, foreign keysโœ… Always

The golden rule: Client-side validation is for UX. Server-side validation is for security. Never substitute one for the other.


Validation Library Comparisonโ€‹

Choosing the right validation library shapes your entire codebase. Here's how the four major contenders stack up:

CriteriaJoiZodYupclass-validator
TypeScript inferenceโŒ Manual typesโœ… First-class, z.inferโŒ Limited (InferType)โŒ Decorator-based, no inference
Bundle size (minified)~150 KB~12 KB~60 KB~40 KB (+ class-transformer)
Learning curveMediumLow (TypeScript-native feel)LowMedium (requires decorator knowledge)
Async validationโœ… external()โœ… .refine() with asyncโœ… .test() with asyncโœ… @Validate with promises
Custom messagesExcellent, template-basedGood, string/method-basedGoodGood, decorator options
Conditional schemasโœ… .when(), .alternatives()โœ… .discriminatedUnion(), .refine()โœ… .when()โœ… @ValidateIf()
Transforms / coercionโœ… Built-inโœ… .transform(), .coerceโœ… .transform()โœ… @Transform() via class-transformer
Framework integrationExpress/Hapi (native)Any (unopinionated)Formik (React), ExpressNestJS (first-class), Express
Ecosystem / pluginsMature (since 2013)Rapidly growing (since 2020)StableNestJS-only
Best forLegacy Express apps, HapiModern TypeScript projects, full-stack (tRPC)React form validation, ExpressNestJS DTO validation

When to Choose Whatโ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Which Validation Library? โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ โ”‚
โ”‚ Are you using NestJS? โ”‚
โ”‚ โ”œโ”€โ”€ Yes โ”€โ”€โ–บ class-validator + ValidationPipe โ”‚
โ”‚ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€ No โ”€โ”€โ–บ Is TypeScript inference critical? โ”‚
โ”‚ โ”œโ”€โ”€ Yes โ”€โ”€โ–บ Zod โ”‚
โ”‚ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€ No โ”€โ”€โ–บ Is bundle size critical? โ”‚
โ”‚ โ”œโ”€โ”€ Yes โ”€โ”€โ–บ Zod โ”‚
โ”‚ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€ No โ”€โ”€โ–บ Joi (mature, โ”‚
โ”‚ feature-rich) โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Joi Deep Diveโ€‹

Joi is the battle-tested validation library for Node.js, maintained by the Hapi.js team. It uses a declarative, chainable API to define schemas that describe exactly what valid data looks like.

Installation & Basic Setupโ€‹

npm install joi
import Joi from 'joi';

// Define a schema
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string()
.pattern(new RegExp('^[a-zA-Z0-9]{8,30}$'))
.message('Password must be 8-30 alphanumeric characters')
.required(),
age: Joi.number().integer().min(18).max(120),
role: Joi.string().valid('user', 'admin', 'moderator').default('user'),
});

// Validate
const { error, value } = userSchema.validate(req.body, {
abortEarly: false, // Return ALL errors, not just the first
stripUnknown: true, // Remove properties not in schema
});

if (error) {
// error.details is an array of { message, path, type, context }
const messages = error.details.map((d) => d.message);
}

Joi Schema Building Blocksโ€‹

Stringsโ€‹

Joi.string()
.min(2)
.max(100)
.alphanum() // a-z, A-Z, 0-9
.email({ tlds: false }) // Email validation
.uri() // Valid URI
.isoDate() // ISO 8601 date
.guid({ version: 'uuidv4' })
.trim() // Auto-trim whitespace
.lowercase() // Auto-lowercase
.uppercase() // Auto-uppercase
.pattern(/^[a-zA-Z ]+$/) // Custom regex
.message('Invalid string format');

Numbersโ€‹

Joi.number()
.integer()
.min(0)
.max(99999)
.positive()
.negative()
.precision(2) // Max 2 decimal places
.port() // 0โ€“65535
.default(0);

Arraysโ€‹

Joi.array()
.items(Joi.string().email()) // Array of emails
.min(1)
.max(100)
.unique() // No duplicates
.ordered(Joi.string(), Joi.number()) // First item string, second number
.single() // Accept single value as array of one
.sparse(false); // Reject undefined/null items

Objects & Nested Schemasโ€‹

const addressSchema = Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
zip: Joi.string().pattern(/^\d{5}(-\d{4})?$/),
country: Joi.string().length(2).uppercase(), // ISO country code
});

const userSchema = Joi.object({
name: Joi.string().required(),
addresses: Joi.array().items(addressSchema).min(1).max(5),
primaryAddress: Joi.number().integer().min(0).max(4), // Index into addresses array
});

Custom Validation with .custom()โ€‹

// Custom validator โ€” synchronous or async
const productSchema = Joi.object({
sku: Joi.string().custom((value, helpers) => {
if (!value.startsWith('SKU-')) {
return helpers.error('sku.prefix', { message: 'SKU must start with "SKU-"' });
}
return value.toUpperCase(); // Can transform!
}),
price: Joi.number().custom((value, helpers) => {
if ((value * 100) % 1 !== 0) {
return helpers.error('price.precision');
}
return value;
}),
});

Conditional Validation with .when()โ€‹

const orderSchema = Joi.object({
paymentMethod: Joi.string().valid('credit_card', 'paypal', 'bank_transfer').required(),

// Conditional: credit card number only required when payment method is 'credit_card'
creditCardNumber: Joi.string().when('paymentMethod', {
is: 'credit_card',
then: Joi.string().creditCard().required(),
otherwise: Joi.string().forbidden(), // Must NOT be present
}),

// Multi-value conditional
shippingAddress: Joi.object().when('paymentMethod', {
is: Joi.string().valid('credit_card', 'paypal'),
then: Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
}).required(),
otherwise: Joi.object().optional(),
}),
});

Async Validation with .external()โ€‹

const registrationSchema = Joi.object({
email: Joi.string()
.email()
.external(async (value) => {
const existingUser = await db.users.findByEmail(value);
if (existingUser) {
throw new Joi.ValidationError(
'Email already registered',
[{ message: 'Email already registered', path: ['email'] }],
value,
);
}
}),
username: Joi.string()
.alphanum()
.external(async (value) => {
const existing = await db.users.findByUsername(value);
if (existing) {
throw new Joi.ValidationError(
'Username taken',
[{ message: 'Username is already taken', path: ['username'] }],
value,
);
}
}),
});

// Async validation must use validateAsync
const validated = await registrationSchema.validateAsync(req.body);

Joi with Express Middlewareโ€‹

import { Request, Response, NextFunction } from 'express';
import Joi, { Schema } from 'joi';

interface ValidationSchemas {
body?: Schema;
params?: Schema;
query?: Schema;
}

/**
* Express middleware factory โ€” validates body, params, and query against Joi schemas.
*/
function validate(schemas: ValidationSchemas) {
return (req: Request, res: Response, next: NextFunction): void => {
const errors: Record<string, string[]> = {};

for (const [location, schema] of Object.entries(schemas)) {
if (!schema) continue;

const { error, value } = schema.validate((req as any)[location], {
abortEarly: false,
stripUnknown: location !== 'query',
});

if (error) {
errors[location] = error.details.map((d) => d.message);
} else {
// Replace with validated & sanitized data
(req as any)[location] = value;
}
}

if (Object.keys(errors).length > 0) {
res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'One or more validation errors occurred',
details: errors,
},
});
return;
}

next();
};
}

// Usage in routes
router.post(
'/users',
validate({
body: Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
}),
params: Joi.object({
orgId: Joi.string().uuid().required(),
}),
query: Joi.object({
invite: Joi.string().valid('true', 'false').default('false'),
}),
}),
userController.create,
);

Zod Deep Diveโ€‹

Zod is a TypeScript-first schema declaration and validation library. Its killer feature: static type inference from schemas, eliminating the need to define types and schemas separately.

Installation & Basic Setupโ€‹

npm install zod
import { z } from 'zod';

// Define a schema โ€” notice the TypeScript-like syntax
const userSchema = z.object({
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_]+$/),
email: z.string().email(),
password: z.string().min(8).max(128),
age: z.number().int().min(18).max(120).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
});

// โœจ STATIC TYPE INFERENCE โ€” no double-declaration needed!
type User = z.infer<typeof userSchema>;
// User = { username: string; email: string; password: string; age?: number; role: 'user' | 'admin' | 'moderator' }

// Parse (throws on failure) vs safeParse (returns result object)
const result = userSchema.safeParse(req.body);

if (!result.success) {
// result.error is a ZodError with .issues array
console.error(result.error.issues);
} else {
// result.data is FULLY TYPED โ€” TypeScript knows its shape
const user: User = result.data;
}

Zod Schema Building Blocksโ€‹

Primitives & Coercionโ€‹

// Coercion โ€” automatically convert strings from query params / form data
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1), // "3" โ†’ 3
limit: z.coerce.number().int().min(1).max(100).default(20),
active: z.coerce.boolean(), // "true" โ†’ true
createdAt: z.coerce.date(), // "2024-01-15" โ†’ Date
});

// Without coercion โ€” strict type checking
const strictSchema = z.object({
page: z.number().int().min(1), // Passes only if page is actually a number
});

Stringsโ€‹

z.string()
.min(1)
.max(255)
.email()
.url()
.uuid()
.cuid()
.datetime() // ISO 8601 datetime
.trim() // Auto-trim
.toLowerCase() // Auto-transform to lowercase
.regex(/^[a-z]+$/i)
.includes('@') // Must contain '@'
.startsWith('USER_')
.length(10);

Numbersโ€‹

z.number().int().positive().nonnegative().min(0).max(100).multipleOf(5).finite().safe(); // Between Number.MIN_SAFE_INTEGER and MAX_SAFE_INTEGER

Arraysโ€‹

z.array(z.string()); // string[]
z.string().array(); // Equivalent syntax

z.array(
z.object({
name: z.string(),
quantity: z.number().int().positive(),
}),
)
.min(1) // At least 1 item
.max(100)
.nonempty() // Alias for .min(1)
.length(5); // Exactly 5 items

Objects โ€” Shape, Extend, Merge, Pick, Omitโ€‹

const baseUser = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
});

// Extend โ€” add fields
const fullUser = baseUser.extend({
password: z.string().min(8),
createdAt: z.date(),
});

// Pick / Omit โ€” subset of fields
const publicUser = fullUser.pick({ id: true, email: true, name: true });
const updateUser = fullUser.omit({ id: true, createdAt: true });

// Merge โ€” combine two schemas
const withMetadata = z.object({ metadata: z.record(z.string()) });
const enrichedUser = fullUser.merge(withMetadata);

// Partial โ€” all fields optional (useful for PATCH)
const patchUser = fullUser.partial();

// Deep partial โ€” optional at all nested levels
const deepPatchUser = fullUser.deepPartial();

// Passthrough / Strip โ€” handle unknown keys
z.object({ email: z.string() }).passthrough(); // Keep unknown keys
z.object({ email: z.string() }).strict(); // Error on unknown keys

Unions, Discriminated Unions & Enumsโ€‹

// Simple union โ€” match any of multiple schemas
const idSchema = z.union([z.string().uuid(), z.number().int().positive()]);

// Discriminated union โ€” tagged union, excellent for event/action types
const eventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('user.created'), userId: z.string(), email: z.string() }),
z.object({ type: z.literal('order.placed'), orderId: z.string(), total: z.number() }),
z.object({ type: z.literal('payment.received'), orderId: z.string(), amount: z.number() }),
]);

type Event = z.infer<typeof eventSchema>;
// TypeScript narrows the type based on the 'type' discriminator โ€” exhaustive checking!

Refinement & Super Refinementโ€‹

// .refine() โ€” simple predicate (returns boolean)
const passwordSchema = z
.string()
.refine((val) => /[A-Z]/.test(val) && /[0-9]/.test(val), {
message: 'Password must contain at least one uppercase letter and one number',
});

// .superRefine() โ€” multi-issue refinement with full control
const bookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
guests: z.number().int().positive(),
})
.superRefine((data, ctx) => {
const now = new Date();

if (data.checkIn < now) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Check-in date must be in the future',
path: ['checkIn'],
});
}

if (data.checkOut <= data.checkIn) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Check-out must be after check-in',
path: ['checkOut'],
});
}

const daysDiff = (data.checkOut.getTime() - data.checkIn.getTime()) / (1000 * 3600 * 24);
if (daysDiff > 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Maximum stay is 30 days',
path: ['checkOut'],
});
}
});

Transforms โ€” Reshaping Data During Validationโ€‹

// Transform a string to a Date
const dateFromString = z.string().transform((val, ctx) => {
const date = new Date(val);
if (isNaN(date.getTime())) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_date,
message: 'Invalid date format',
});
return z.NEVER; // Abort the transform
}
return date;
});

// Trim + lowercase + transform
const emailSchema = z
.string()
.trim()
.toLowerCase()
.email()
.transform((email) => email.split('@')[1]) // Extract domain
.refine((domain) => !domain.endsWith('.test'), 'Test domains not allowed');

// Preprocess โ€” transform BEFORE validation
const numericId = z.preprocess(
(val) => (typeof val === 'string' ? parseInt(val, 10) : val),
z.number().int().positive(),
);
// numericId.parse("123") โ†’ 123
// numericId.parse("abc") โ†’ throws (NaN fails .int().positive())

Zod with Express Middlewareโ€‹

import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';

/**
* Generic Zod validation middleware.
* Validates body, params, and query against provided Zod schemas.
* On success: replaces req.{body,params,query} with parsed (coerced & sanitized) data.
* On failure: returns 422 with structured error details.
*/
function validate(schemas: { body?: ZodSchema; params?: ZodSchema; query?: ZodSchema }) {
return (req: Request, res: Response, next: NextFunction): void => {
const errors: Array<{ location: string; field: string; message: string }> = [];

for (const [location, schema] of Object.entries(schemas)) {
if (!schema) continue;

const result = schema.safeParse((req as any)[location]);

if (!result.success) {
const zodError = result.error as ZodError;
for (const issue of zodError.issues) {
errors.push({
location,
field: issue.path.join('.'),
message: issue.message,
});
}
} else {
(req as any)[location] = result.data;
}
}

if (errors.length > 0) {
res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'One or more validation errors occurred',
details: errors,
},
});
return;
}

next();
};
}

// Usage
router.post(
'/orders',
validate({
body: z.object({
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(99),
}),
)
.min(1)
.max(50),
shippingAddress: z.string().min(20).max(500),
}),
query: z.object({
coupon: z.string().optional(),
}),
}),
orderController.create,
);

class-validator with NestJSโ€‹

class-validator works with TypeScript decorators to annotate class properties with validation rules. When paired with NestJS's ValidationPipe, validation becomes nearly invisible โ€” DTOs self-validate.

Installation & NestJS Setupโ€‹

npm install class-validator class-transformer
// main.ts โ€” Enable global validation
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not in DTO
forbidNonWhitelisted: true, // Throw error on unknown properties
transform: true, // Auto-transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: true, // Auto-coerce types from query strings
},
validationError: {
target: false, // Don't expose the DTO in error response
value: false, // Don't expose the invalid value
},
}),
);

await app.listen(3000);
}
bootstrap();

DTOs with Decoratorsโ€‹

import {
IsString,
IsEmail,
IsInt,
Min,
Max,
IsEnum,
IsOptional,
IsUUID,
IsArray,
ArrayMinSize,
ArrayMaxSize,
ValidateNested,
IsBoolean,
IsDateString,
Matches,
Length,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';

// --- Nested DTO ---
class OrderItemDto {
@IsUUID('4')
productId: string;

@IsInt()
@Min(1)
@Max(99)
quantity: number;

@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}

// --- Main DTO ---
export class CreateOrderDto {
@IsUUID('4')
userId: string;

@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(50)
@ValidateNested({ each: true }) // Validate EACH item in the array
@Type(() => OrderItemDto) // Required for nested validation
items: OrderItemDto[];

@IsString()
@Length(20, 500)
shippingAddress: string;

@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{4,10}$/)
couponCode?: string;

@IsEnum(['standard', 'express', 'overnight'])
shippingMethod: 'standard' | 'express' | 'overnight';
}

// --- Controller ---
import { Body, Controller, Post, Query, Param, Get } from '@nestjs/common';

@Controller('orders')
export class OrdersController {
@Post()
create(@Body() dto: CreateOrderDto) {
// dto is already validated AND typed correctly!
return this.ordersService.create(dto);
}

@Get()
findAll(@Query('page') page: number = 1, @Query('limit') limit: number = 20) {
return this.ordersService.findAll({ page, limit });
}
}

Custom Decoratorsโ€‹

import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';

// --- Custom validator: IsStrongPassword ---
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
validate(password: string, args: ValidationArguments): boolean {
if (typeof password !== 'string') return false;
const hasUpper = /[A-Z]/.test(password);
const hasLower = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const isValidLength = password.length >= 8 && password.length <= 128;

// Count how many conditions pass
const passed = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
return isValidLength && passed >= 3;
}

defaultMessage(args: ValidationArguments): string {
return 'Password must be 8-128 characters and include at least 3 of: uppercase, lowercase, number, special character';
}
}

export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsStrongPasswordConstraint,
});
};
}

// --- Custom validator: IsAfterDate ---
@ValidatorConstraint({ name: 'isAfterDate', async: false })
export class IsAfterDateConstraint implements ValidatorConstraintInterface {
validate(value: string, args: ValidationArguments): boolean {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
if (!value || !relatedValue) return true; // Let @IsNotEmpty handle missing values
return new Date(value) > new Date(relatedValue);
}

defaultMessage(args: ValidationArguments): string {
return `${args.property} must be after ${args.constraints[0]}`;
}
}

export function IsAfterDate(property: string, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [property],
validator: IsAfterDateConstraint,
});
};
}

// --- Usage in DTO ---
export class CreateBookingDto {
@IsDateString()
checkIn: string;

@IsDateString()
@IsAfterDate('checkIn')
checkOut: string;

@IsStrongPassword()
password: string;
}

Async Custom Validators (DB Lookups)โ€‹

@ValidatorConstraint({ name: 'isUniqueEmail', async: true })
@Injectable() // Make injectable to use services
export class IsUniqueEmailConstraint implements ValidatorConstraintInterface {
constructor(private readonly usersService: UsersService) {}

async validate(email: string, args: ValidationArguments): Promise<boolean> {
if (!email) return true;
const user = await this.usersService.findByEmail(email);
return !user; // Return true if email is NOT taken
}

defaultMessage(args: ValidationArguments): string {
return `Email "${args.value}" is already registered`;
}
}

export function IsUniqueEmail(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsUniqueEmailConstraint,
});
};
}

// Usage in DTO
export class RegisterDto {
@IsEmail()
@IsUniqueEmail()
email: string;
}

// IMPORTANT: Register the constraint as a provider in your module
@Module({
providers: [IsUniqueEmailConstraint, UsersService],
})
export class UsersModule {}

Conditional Validation with @ValidateIfโ€‹

import { ValidateIf } from 'class-validator';

export class PaymentDto {
@IsEnum(['credit_card', 'paypal', 'bank_transfer'])
paymentMethod: string;

@ValidateIf((o) => o.paymentMethod === 'credit_card')
@IsCreditCard()
cardNumber?: string;

@ValidateIf((o) => o.paymentMethod === 'credit_card')
@IsString()
@Length(3, 4)
cvv?: string;

@ValidateIf((o) => o.paymentMethod === 'bank_transfer')
@IsString()
@Matches(/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/)
iban?: string;
}

Validating All Input Typesโ€‹

Unvalidated request data can come from four locations. All must be validated:

flowchart TD
R[Incoming HTTP Request] --> B[Body]
R --> P[Params / URL Segments]
R --> Q[Query String]
R --> H[Headers]

B --> V{Validation Layer}
P --> V
Q --> V
H --> V

V -->|Valid| C[Controller / Handler]
V -->|Invalid| E[422 Unprocessable Entity]

subgraph "Trust Boundary"
direction LR
V
end

style E fill:#ffcdd2,stroke:#b71c1c
style C fill:#c8e6c9,stroke:#1b5e20

Body Validationโ€‹

// Express + Zod
const createUserBody = z.object({
email: z.string().email(),
password: z.string().min(8),
});

URL Parameters Validationโ€‹

// Express + Zod
const userIdParams = z.object({
userId: z.string().uuid(),
});

router.get('/users/:userId', validate({ params: userIdParams }), userController.getById);

Query String Validationโ€‹

Query strings are always strings โ€” coercion is critical:

// Express + Zod โ€” use z.coerce for all query values
const listUsersQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().max(200).optional(),
status: z.enum(['active', 'inactive', 'banned']).optional(),
role: z.string().optional(),
});

// curl "http://api.example.com/users?page=2&limit=10&status=active"
// page="2" โ†’ coerce to 2, limit="10" โ†’ coerce to 10

Headers Validationโ€‹

// Express + Zod
const authHeaders = z.object({
authorization: z.string().regex(/^Bearer [A-Za-z0-9\-._~+/]+=*$/),
'content-type': z.literal('application/json'),
'x-request-id': z.string().uuid().optional(),
});

router.post('/secure-endpoint', validate({ headers: authHeaders }), (req, res) => {
/* ... */
});

Nested & Array Validationโ€‹

Real-world payloads contain deeply nested structures. Every level must be validated.

// Zod โ€” recursive nested schema
const attributeSchema = z.object({
key: z.string().min(1).max(50),
value: z.union([z.string(), z.number(), z.boolean()]),
});

const variantSchema = z.object({
sku: z.string().regex(/^[A-Z]{2}-\d{6}$/),
size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL']),
color: z.string(),
price: z.number().positive().max(99999.99),
attributes: z.array(attributeSchema).max(20),
});

const createProductSchema = z.object({
name: z.string().min(3).max(200),
variants: z
.array(variantSchema)
.min(1)
.max(100)
.refine(
(variants) => {
// Business rule: all SKUs within a product must be unique
const skus = variants.map((v) => v.sku);
return new Set(skus).size === skus.length;
},
{ message: 'Duplicate SKU within product variants' },
),
tags: z.array(z.string().min(1).max(30)).max(20).default([]),
metadata: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
});

Custom Business Rule Validationโ€‹

Schema validation handles structure and types. Business rules enforce domain constraints โ€” combinations of fields that must (or must not) exist together.

// Example: Shipment validation with complex business rules
const shipmentSchema = z
.object({
origin: z.object({
country: z.string().length(2),
city: z.string().min(1),
postalCode: z.string(),
}),
destination: z.object({
country: z.string().length(2),
city: z.string().min(1),
postalCode: z.string(),
}),
packageDetails: z.object({
weightKg: z.number().positive().max(1000),
dimensionsCm: z.object({
length: z.number().positive().max(300),
width: z.number().positive().max(300),
height: z.number().positive().max(300),
}),
isHazardous: z.boolean(),
isFragile: z.boolean(),
}),
shippingSpeed: z.enum(['standard', 'express', 'overnight']),
})
.superRefine((data, ctx) => {
// Rule 1: Domestic shipments can't be 'overnight'
if (data.origin.country === data.destination.country && data.shippingSpeed === 'overnight') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Overnight shipping is only available for international shipments',
path: ['shippingSpeed'],
});
}

// Rule 2: Hazardous materials can't use express or overnight
if (data.packageDetails.isHazardous && data.shippingSpeed !== 'standard') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Hazardous materials can only be shipped via standard',
path: ['shippingSpeed'],
});
}

// Rule 3: Fragile items must have volumetric weight under 30kg
const volumetricWeight =
(data.packageDetails.dimensionsCm.length *
data.packageDetails.dimensionsCm.width *
data.packageDetails.dimensionsCm.height) /
5000;
if (data.packageDetails.isFragile && volumetricWeight > 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Fragile items must have volumetric weight under 30kg',
path: ['packageDetails', 'dimensionsCm'],
});
}

// Rule 4: Specific country restrictions
const restrictedDestinations = ['KP', 'IR', 'SY', 'CU'];
if (restrictedDestinations.includes(data.destination.country)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Shipping to ${data.destination.country} is restricted`,
path: ['destination', 'country'],
});
}
});

Input Sanitizationโ€‹

Validation rejects bad data. Sanitization cleans acceptable data. Both are necessary.

What to Sanitizeโ€‹

Input TypeRiskSanitization
Free-text fields (comments, bios)XSS via stored <script> tagsStrip HTML, or use allowlist-based HTML sanitizer
File namesPath traversal (../../etc/passwd)Strip path separators, allow only safe characters
URLs / redirect paramsOpen redirect phishingValidate against allowlist of domains
Rich text (WYSIWYG editors)XSS, CSS injectionSanitize with DOMPurify (server-side)
MarkdownXSS via raw HTML in markdownRender then sanitize, or strip raw HTML before rendering

XSS Sanitizationโ€‹

npm install dompurify jsdom
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';

const window = new JSDOM('').window;
const purify = DOMPurify(window as any);

function sanitizeHtml(dirty: string): string {
return purify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title', 'target'],
ALLOW_DATA_ATTR: false,
});
}

// Intercept in validation middleware or use as Zod transform
const commentSchema = z
.string()
.max(5000)
.transform((val) => sanitizeHtml(val))
.refine((val) => val.length > 0, 'Comment cannot be empty after sanitization');

SQL Injection โ€” Parameterized Queries (the only correct answer)โ€‹

// โŒ NEVER do this โ€” SQL injection vulnerability
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// โœ… ALWAYS use parameterized queries / ORM
// Raw SQL with parameterized query
const { rows } = await pool.query('SELECT * FROM users WHERE email = $1', [req.body.email]);

// ORM (Prisma example โ€” parameterized by design)
const user = await prisma.user.findUnique({
where: { email: req.body.email },
});

NoSQL Injection Preventionโ€‹

// โŒ Dangerous โ€” user controls the filter object
await db.collection('users').find(req.body.filter);

// โœ… Whitelist acceptable filter fields
const allowedFields = ['email', 'username', 'status'];
const filter: Record<string, unknown> = {};
for (const [key, value] of Object.entries(req.body.filter)) {
if (allowedFields.includes(key) && typeof value === 'string') {
filter[key] = value;
}
}
await db.collection('users').find(filter);

Async Validation Patternsโ€‹

Some validations require database queries or external service calls. Handle them carefully โ€” they add latency and can be exploited for enumeration attacks.

Zod Async Refinementโ€‹

const registerSchema = z
.object({
email: z.string().email(),
username: z.string().min(3).max(30),
})
.refine(
async (data) => {
// Returns true if email is NOT taken
const existing = await userRepository.findByEmail(data.email);
return !existing;
},
{ message: 'Email is already registered', path: ['email'] },
)
.refine(
async (data) => {
const existing = await userRepository.findByUsername(data.username);
return !existing;
},
{ message: 'Username is already taken', path: ['username'] },
);

// Must use .parseAsync() or .safeParseAsync()
const result = await registerSchema.safeParseAsync(req.body);

Performance Considerations for Async Validationโ€‹

// โœ… BATCH: Parallelize independent async checks
const [emailExists, usernameExists] = await Promise.all([
userRepository.findByEmail(data.email),
userRepository.findByUsername(data.username),
]);

// โœ… CACHE: Cache expensive lookups to avoid repeated DB hits
// โœ… TIMEOUT: Add timeouts to external service calls
const product = await Promise.race([
externalCatalogService.lookup(data.productId),
new Promise((_, reject) => setTimeout(() => reject(new Error('Catalog service timeout')), 2000)),
]);

Error Response Formattingโ€‹

Validation errors must be structured, consistent, and actionable. Clients parse error responses programmatically โ€” make them predictable.

Standard Error Formatโ€‹

{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more validation errors occurred",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"rejectedValue": "not-an-email"
},
{
"field": "items.0.quantity",
"message": "Must be at least 1",
"rejectedValue": 0
},
{
"field": "checkOut",
"message": "Check-out must be after check-in",
"rejectedValue": "2024-01-10T00:00:00.000Z"
}
],
"requestId": "req_c9d8f7e6"
}
}

Generic Validation Error Handler (Express)โ€‹

import { ZodError } from 'zod';
import { Request, Response, NextFunction } from 'express';

class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: any[],
) {
super(message);
}
}

function validationErrorHandler(err: Error, req: Request, res: Response, next: NextFunction): void {
if (err instanceof ZodError) {
res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'One or more validation errors occurred',
details: err.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
rejectedValue: 'value' in req.body ? (req.body as any)[issue.path[0]] : undefined,
})),
requestId: req.headers['x-request-id'] || undefined,
},
});
return;
}

if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
requestId: req.headers['x-request-id'] || undefined,
},
});
return;
}

// Unexpected error โ€” log it, return generic message
console.error('Unhandled error:', err);
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.headers['x-request-id'] || undefined,
},
});
}

// Register after all routes
app.use(validationErrorHandler);

NestJS Exception Filter for Validation Errorsโ€‹

import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { Response, Request } from 'express';

@Catch(BadRequestException)
export class ValidationExceptionFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const exceptionResponse = exception.getResponse() as any;

// class-validator errors come as an array on the message property
const details = Array.isArray(exceptionResponse.message)
? exceptionResponse.message.map((msg: string) => ({
field: this.extractFieldName(msg),
message: msg,
}))
: [{ field: 'unknown', message: exceptionResponse.message }];

response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({
error: {
code: 'VALIDATION_ERROR',
message: 'One or more validation errors occurred',
details,
requestId: request.headers['x-request-id'] || undefined,
},
});
}

private extractFieldName(message: string): string {
// class-validator messages often start with the field name
const match = message.match(/^(\w+)\s/);
return match ? match[1] : 'unknown';
}
}

// Register in main.ts
app.useGlobalFilters(new ValidationExceptionFilter());

Complete Validation Pipelineโ€‹

flowchart TD
REQ[("Incoming HTTP Request")] --> EXTRACT[Extract Input Sources]
EXTRACT --> BODY["Body (JSON/Form/XML)"]
EXTRACT --> PARAMS["URL Params"]
EXTRACT --> QUERY["Query String"]
EXTRACT --> HEADERS["Headers"]

BODY --> COERCE{"Coercion Layer"}
PARAMS --> COERCE
QUERY --> COERCE
HEADERS --> COERCE

COERCE --> SANITIZE{"Sanitization"}
SANITIZE -->|"Strip HTML, trim, normalize"| SCHEMA{"Schema Validation"}

SCHEMA -->|"Valid structure & types"| BUS{"Business Rules"}
SCHEMA -->|"Invalid"| ERR422["422 Validation Error"]

BUS -->|"Business rules satisfied"| ASYNC{"Async Validation"}
BUS -->|"Rule violation"| ERR422

ASYNC -->|"DB checks, external services"| CLEAN["Sanitized & Validated Data"]
ASYNC -->|"Failed"| ERR422

CLEAN --> HANDLER["Route Handler / Controller"]

ERR422 --> RESPONSE["Structured Error Response"]
RESPONSE --> CLIENT["Client"]

style REQ fill:#e3f2fd,stroke:#1565c0
style CLEAN fill:#c8e6c9,stroke:#2e7d32
style ERR422 fill:#ffcdd2,stroke:#c62828
style RESPONSE fill:#fff9c4,stroke:#f9a825
style SCHEMA fill:#e1bee7,stroke:#7b1fa2
style BUS fill:#ffe0b2,stroke:#e65100
style ASYNC fill:#b2dfdb,stroke:#00695c

Best Practices Summaryโ€‹

#PracticeWhy
1Validate at the boundaryCatch bad data before it enters your domain layer
2Use schema-first validationSchemas are self-documenting, testable, and reusable
3Prefer Zod for TypeScript projectsEliminate dual type/schema maintenance with z.infer
4Always strip unknown propertiesPrevents mass-assignment attacks (stripUnknown / whitelist)
5Validate all four input sourcesBody, params, query, headers โ€” no blind spots
6Coerce query paramsEverything from a query string is a string
7Use discriminated unions for polymorphic payloadsExhaustive type narrowing in TypeScript
8Format errors consistentlyClients should get the same structure from every endpoint
9Never expose stack traces or internal details in errorsProduction errors should be generic; details go to logs
10Batch independent async validationsPromise.all to minimize latency
11Sanitize free-text fieldsXSS prevention even if HTML is never "supposed" to be there
12Use parameterized queries โ€” alwaysThe only reliable defense against SQL injection
13Validate file uploads separatelyCheck MIME type, magic bytes, file size BEFORE processing
14Test your validationWrite unit tests for schemas and integration tests for middleware

Quick Decision Matrixโ€‹

SituationToolPattern
NestJS app, DTOs with decoratorsclass-validator + ValidationPipe@IsString(), @IsEmail(), whitelist: true
TypeScript Express app, team values type safetyZodz.object({ ... }).safeParse(req.body)
Legacy Node.js app, Hapi frameworkJoiJoi.object({ ... }).validate(req.payload)
React form validation shared with serverZodShare schemas via monorepo package
Simple API, minimal dependenciesZod (12 KB)Lightweight, tree-shakeable
Complex conditional logic, alternativesJoi .when() / Zod .discriminatedUnion()Match library to complexity
DB lookup required for uniquenessZod .refine(async ...) / class-validator asyncUse safeParseAsync or async custom validator

โ† Back to Backend Engineering ยท ยฉ sparshjaswal