Loading learning content...
Every catastrophic software failure has a common origin: bad data entering the system. SQL injection attacks, buffer overflows, null pointer exceptions, business logic corruption—all trace back to insufficient input validation. In 2017, the Equifax breach exposed 147 million records partly due to inadequate input validation. In 2021, Log4Shell exploited improper input handling, affecting millions of systems worldwide.
Input validation isn't merely defensive coding—it's the architectural foundation for system integrity, security, and reliability. This page provides a comprehensive examination of input validation strategies that separate amateur code from production-grade systems.
By the end of this page, you will understand the taxonomy of validation strategies, where validation should occur in your architecture, how to design validation layers that compose correctly, and the trade-offs between different approaches. You'll be equipped to design validation systems that are both robust and maintainable.
Input validation encompasses multiple distinct concerns, each requiring different strategies. Understanding this taxonomy is essential for designing comprehensive validation systems.
Syntactic Validation verifies that input conforms to expected format and structure. This includes type checking, format validation (email patterns, phone numbers), and encoding verification. Syntactic validation answers: Is this input well-formed?
Semantic Validation ensures input values make sense within the domain context. A syntactically valid date like '2024-02-30' fails semantic validation because February never has 30 days. Semantic validation answers: Does this input make sense?
Business Rule Validation confirms input satisfies domain-specific constraints. An order quantity of 1000 might be syntactically and semantically valid but violate business rules limiting orders to 100 units. Business validation answers: Is this input allowed?
| Validation Type | Focus | Examples | Typical Location |
|---|---|---|---|
| Syntactic | Format & Structure | Type checking, regex patterns, JSON schema | API boundary, DTOs |
| Semantic | Meaning & Coherence | Date validity, range checks, referential integrity | Domain layer, Value Objects |
| Business Rule | Domain Constraints | Credit limits, inventory availability, authorization | Domain services, Aggregates |
| Security | Attack Prevention | SQL injection, XSS, path traversal | All layers, especially boundaries |
Most validation failures occur because developers conflate these categories. Syntactic validation alone provides false security—a perfectly formatted email address can still be invalid, not owned by the user, or associated with a banned account. Comprehensive validation addresses all categories systematically.
One of the most contentious architectural decisions is where validation logic belongs. The answer isn't singular—different validation types belong at different layers, and some validation must occur at multiple points.
The Boundary Principle: Validate all external input at system boundaries before it enters your domain. This includes API endpoints, message queue consumers, file upload handlers, and configuration loaders. Boundary validation prevents malformed data from propagating into system internals.
The Domain Principle: Business rules and semantic validation belong in the domain layer. Value Objects should enforce their own invariants. Entities should validate state transitions. This ensures domain integrity regardless of how data enters the system.
The Defense-in-Depth Principle: Critical validations should occur at multiple layers. Don't trust that upstream validation occurred correctly. This redundancy protects against bugs, refactoring errors, and security vulnerabilities.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// BOUNDARY LAYER: API Controller - Syntactic validationclass OrderController { async createOrder(request: HttpRequest): Promise<HttpResponse> { // Schema validation at the boundary const parseResult = CreateOrderSchema.safeParse(request.body); if (!parseResult.success) { return HttpResponse.badRequest(parseResult.error.issues); } // Validated DTO enters the application layer const result = await this.orderService.createOrder(parseResult.data); return result.match({ success: (order) => HttpResponse.created(order), failure: (error) => HttpResponse.unprocessableEntity(error) }); }} // APPLICATION LAYER: Orchestrates domain operationsclass OrderService { async createOrder(dto: CreateOrderDto): Promise<Result<Order, OrderError>> { // Application-level validation (authorization, resource existence) const customer = await this.customerRepo.findById(dto.customerId); if (!customer) { return Result.failure(OrderError.customerNotFound(dto.customerId)); } // Delegate to domain for business validation return this.orderFactory.create(customer, dto.items); }} // DOMAIN LAYER: Business rule validation in Value Objectsclass Money { private constructor( private readonly amount: number, private readonly currency: Currency ) {} static create(amount: number, currency: Currency): Result<Money, ValidationError> { // Semantic validation: amounts must be non-negative if (amount < 0) { return Result.failure(ValidationError.negativeAmount(amount)); } // Semantic validation: precision must match currency if (!currency.isValidPrecision(amount)) { return Result.failure(ValidationError.invalidPrecision(amount, currency)); } return Result.success(new Money(amount, currency)); }} // DOMAIN LAYER: Business rules in Aggregateclass Order { addItem(product: Product, quantity: Quantity): Result<void, OrderError> { // Business rule: cannot add items to shipped orders if (this.status === OrderStatus.Shipped) { return Result.failure(OrderError.cannotModifyShippedOrder()); } // Business rule: maximum items per order if (this.items.length >= Order.MAX_ITEMS) { return Result.failure(OrderError.maxItemsExceeded()); } // Business rule: inventory availability if (!product.hasInventory(quantity)) { return Result.failure(OrderError.insufficientInventory(product.id)); } this.items.push(new OrderItem(product, quantity)); return Result.success(); }}Effective validation requires choosing appropriate patterns for different scenarios. Each pattern has distinct characteristics suited to specific use cases.
EmailAddress type that can only contain valid emails eliminates scattered validation checks. The type system becomes your validation system.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// PATTERN 1: Schema Validation with Zodimport { z } from 'zod'; const CreateUserSchema = z.object({ email: z.string().email("Invalid email format"), password: z.string() .min(8, "Password must be at least 8 characters") .regex(/[A-Z]/, "Password must contain uppercase letter") .regex(/[0-9]/, "Password must contain a number"), age: z.number().int().min(18, "Must be 18 or older"), role: z.enum(["user", "admin", "moderator"]),}); type CreateUserDto = z.infer<typeof CreateUserSchema>; // PATTERN 2: Specification Pattern for Business Rulesinterface Specification<T> { isSatisfiedBy(candidate: T): boolean; and(other: Specification<T>): Specification<T>; or(other: Specification<T>): Specification<T>; not(): Specification<T>;} class OrderValueSpecification implements Specification<Order> { constructor(private readonly minValue: Money) {} isSatisfiedBy(order: Order): boolean { return order.totalValue.isGreaterThanOrEqual(this.minValue); } and(other: Specification<Order>): Specification<Order> { return new AndSpecification(this, other); } // ... or, not implementations} // Compose complex business rulesconst eligibleForFreeShipping = new OrderValueSpecification(Money.dollars(50)) .and(new CustomerTierSpecification(CustomerTier.Gold)) .or(new PromotionActiveSpecification("FREE_SHIP_2024")); // PATTERN 3: Validation Result Pattern (accumulating errors)class ValidationResult { private constructor( private readonly errors: ValidationError[] ) {} static valid(): ValidationResult { return new ValidationResult([]); } static invalid(...errors: ValidationError[]): ValidationResult { return new ValidationResult(errors); } combine(other: ValidationResult): ValidationResult { return new ValidationResult([...this.errors, ...other.errors]); } get isValid(): boolean { return this.errors.length === 0; } get allErrors(): ValidationError[] { return [...this.errors]; }} // Usage: Accumulate all errors for user feedbackfunction validateUserProfile(data: UserProfileData): ValidationResult { return ValidationResult.valid() .combine(validateEmail(data.email)) .combine(validatePhoneNumber(data.phone)) .combine(validateAddress(data.address)) .combine(validateDateOfBirth(data.dateOfBirth));}Security validation requires a fundamentally different mindset: assume all input is malicious. Attackers craft inputs specifically designed to bypass validation and exploit system vulnerabilities.
| Attack | Description | Validation Defense |
|---|---|---|
| SQL Injection | Malicious SQL in user input | Parameterized queries, input sanitization, allowlists |
| XSS | Script injection in output | Output encoding, Content Security Policy, sanitization |
| Path Traversal | Access files outside intended directory | Canonical path validation, allowlist directories |
| Command Injection | OS commands in input | Never construct commands from input, strict allowlists |
| Integer Overflow | Values exceeding type bounds | Range validation before arithmetic, safe math libraries |
| Deserialization | Malicious serialized objects | Type allowlists, avoid deserializing untrusted data |
Always validate against allowlists (what IS permitted), never blocklists (what IS NOT permitted). Blocklists inevitably miss attack vectors. If you expect a color, validate against ['red', 'green', 'blue']—don't try to block every possible malicious string.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// SECURE: Allowlist validation for file uploadsconst ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);const ALLOWED_MIME_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp']); function validateFileUpload(file: UploadedFile): Result<void, SecurityError> { // Validate extension (case-insensitive) const ext = path.extname(file.originalName).toLowerCase(); if (!ALLOWED_EXTENSIONS.has(ext)) { return Result.failure(SecurityError.invalidFileType(ext)); } // Validate MIME type from actual file content, not headers const detectedMime = detectMimeType(file.buffer); if (!ALLOWED_MIME_TYPES.has(detectedMime)) { return Result.failure(SecurityError.mimeTypeMismatch(detectedMime)); } // Validate file size if (file.size > MAX_FILE_SIZE) { return Result.failure(SecurityError.fileTooLarge(file.size)); } // Validate filename doesn't contain path traversal if (file.originalName.includes('..') || file.originalName.includes('/')) { return Result.failure(SecurityError.pathTraversal()); } return Result.success();} // SECURE: Path validation preventing directory traversalclass SecureFileAccess { constructor(private readonly baseDirectory: string) { // Ensure base directory is absolute and canonical this.baseDirectory = path.resolve(baseDirectory); } getFilePath(userProvidedPath: string): Result<string, SecurityError> { // Resolve to absolute path const requestedPath = path.resolve(this.baseDirectory, userProvidedPath); // Verify it's still within base directory (canonical path comparison) if (!requestedPath.startsWith(this.baseDirectory + path.sep)) { return Result.failure(SecurityError.pathTraversal()); } return Result.success(requestedPath); }}Production systems require validation logic that is composable, reusable, and testable. This demands treating validators as first-class architectural components rather than scattered conditionals.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Composable Validator Interfaceinterface Validator<T, E = ValidationError> { validate(value: T): Result<T, E[]>;} // Validator Combinatorsclass Validators { // Combine validators, collecting all errors static all<T>(...validators: Validator<T>[]): Validator<T> { return { validate(value: T): Result<T, ValidationError[]> { const errors: ValidationError[] = []; for (const validator of validators) { const result = validator.validate(value); if (result.isFailure) { errors.push(...result.error); } } return errors.length > 0 ? Result.failure(errors) : Result.success(value); } }; } // Chain validators, stopping on first failure static chain<T>(...validators: Validator<T>[]): Validator<T> { return { validate(value: T): Result<T, ValidationError[]> { for (const validator of validators) { const result = validator.validate(value); if (result.isFailure) { return result; } } return Result.success(value); } }; } // Conditional validator static when<T>( condition: (value: T) => boolean, validator: Validator<T> ): Validator<T> { return { validate(value: T): Result<T, ValidationError[]> { if (condition(value)) { return validator.validate(value); } return Result.success(value); } }; }} // Reusable primitive validatorsconst nonEmpty = (field: string): Validator<string> => ({ validate: (value) => value.trim().length > 0 ? Result.success(value) : Result.failure([ValidationError.required(field)])}); const maxLength = (max: number, field: string): Validator<string> => ({ validate: (value) => value.length <= max ? Result.success(value) : Result.failure([ValidationError.tooLong(field, max)])}); // Compose domain-specific validators from primitivesconst usernameValidator = Validators.all( nonEmpty("username"), maxLength(50, "username"), { validate: (value: string) => /^[a-zA-Z0-9_]+$/.test(value) ? Result.success(value) : Result.failure([ValidationError.invalidFormat("username")]) });You now understand the comprehensive landscape of input validation strategies. Next, we'll explore guard clauses—a powerful technique for validating preconditions at function entry points that dramatically improves code clarity and correctness.