Building Bulletproof APIs in NestJS: Validation, Serialization, and Versioning
Garbage in, garbage out? Not on our watch. Deep dive into protecting your NestJS endpoints using Pipes, class-validator, and class-transformer. This guide covers complex scenarios like conditional validation, custom decorators, and ensuring your API evolves safely with URI versioning.
5. Validation & Serialization
Class-validator Integration
Class-validator provides decorator-based validation for DTOs, automatically triggered by NestJS's ValidationPipe—it's the standard approach for request validation in NestJS applications.
// create-user.dto.ts export class CreateUserDto { @IsEmail() @IsNotEmpty() email: string; @IsString() @MinLength(8) @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { message: 'Password must contain uppercase, lowercase, and number', }) password: string; @IsInt() @Min(18) @Max(120) age: number; @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; } // main.ts app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip non-decorated properties forbidNonWhitelisted: true, // Throw on extra properties transform: true, // Auto-transform types }));
Class-transformer Integration
Class-transformer converts plain objects to class instances and vice versa, enabling type transformations, property exclusion, and custom serialization logic through decorators.
// user.entity.ts export class User { id: number; email: string; @Exclude() password: string; @Expose({ name: 'fullName' }) getFullName() { return `${this.firstName} ${this.lastName}`; } @Transform(({ value }) => value.toISOString()) createdAt: Date; @Type(() => Role) roles: Role[]; } // Usage const user = plainToInstance(User, plainObject); const plain = instanceToPlain(userInstance);
Custom Validation Decorators
Custom validators extend class-validator for domain-specific rules like checking uniqueness in the database or validating business logic that built-in decorators can't handle.
// is-unique.validator.ts @ValidatorConstraint({ async: true }) @Injectable() export class IsUniqueConstraint implements ValidatorConstraintInterface { constructor(private dataSource: DataSource) {} async validate(value: any, args: ValidationArguments) { const [entity, field] = args.constraints; const repo = this.dataSource.getRepository(entity); const exists = await repo.findOne({ where: { [field]: value } }); return !exists; } defaultMessage(args: ValidationArguments) { return `${args.property} already exists`; } } export function IsUnique(entity: Function, field: string, options?: ValidationOptions) { return function (object: Object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options, constraints: [entity, field], validator: IsUniqueConstraint, }); }; } // Usage @IsUnique(User, 'email') email: string;
Validation Groups
Validation groups allow different validation rules for the same DTO in different contexts—essential when create and update operations have different requirements.
// user.dto.ts export class UserDto { @IsNotEmpty({ groups: ['create'] }) @IsOptional({ groups: ['update'] }) @IsEmail() email: string; @IsNotEmpty({ groups: ['create'] }) @IsOptional({ groups: ['update'] }) @MinLength(8) password: string; @IsOptional({ groups: ['create', 'update'] }) @IsString() bio?: string; } // Controller @Post() create( @Body(new ValidationPipe({ groups: ['create'] })) dto: UserDto ) {} @Patch(':id') update( @Body(new ValidationPipe({ groups: ['update'] })) dto: UserDto ) {}
Conditional Validation
Conditional validation applies rules based on other property values—useful for forms where certain fields become required only when specific options are selected.
export class PaymentDto { @IsIn(['credit_card', 'bank_transfer', 'crypto']) paymentMethod: string; @ValidateIf(o => o.paymentMethod === 'credit_card') @IsCreditCard() @IsNotEmpty() cardNumber?: string; @ValidateIf(o => o.paymentMethod === 'bank_transfer') @IsIBAN() @IsNotEmpty() iban?: string; @ValidateIf(o => o.paymentMethod === 'crypto') @Matches(/^0x[a-fA-F0-9]{40}$/) @IsNotEmpty() walletAddress?: string; } // Custom conditional decorator export function RequiredIfStatus(status: string) { return ValidateIf((o, v) => o.status === status); }
Serialization and Exclusion Strategies
Serialization strategies control what data is exposed in responses based on context—use @Exclude() and @Expose() with groups to create different response shapes for different scenarios.
// user.entity.ts @Exclude() export class User { @Expose() id: number; @Expose() email: string; password: string; // Excluded by default @Expose({ groups: ['admin'] }) internalNotes: string; @Expose({ groups: ['admin', 'self'] }) phoneNumber: string; @Expose() @Transform(({ obj }) => obj.firstName + ' ' + obj.lastName) displayName: string; } // Serializer interceptor with groups @Injectable() export class SerializerInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const groups = this.determineGroups(request.user); return next.handle().pipe( map(data => instanceToPlain(data, { groups })), ); } }
Response Transformation
Response transformation reshapes outgoing data using interceptors—commonly used for wrapping responses in a standard envelope, adding metadata, or transforming entities to DTOs.
// transform.interceptor.ts @Injectable() export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> { intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> { const request = context.switchToHttp().getRequest(); return next.handle().pipe( map(data => ({ success: true, data, meta: { timestamp: new Date().toISOString(), path: request.url, version: 'v1', }, })), ); } } // Output: // { // "success": true, // "data": { "id": 1, "email": "user@example.com" }, // "meta": { // "timestamp": "2024-01-15T10:30:00.000Z", // "path": "/users/1", // "version": "v1" // } // }
Versioning
API versioning maintains backward compatibility while evolving your API—NestJS supports URI, header, and media type versioning strategies out of the box.
// main.ts app.enableVersioning({ type: VersioningType.URI, // /v1/users, /v2/users // type: VersioningType.HEADER, // X-API-Version: 1 // type: VersioningType.MEDIA_TYPE, // Accept: application/json;v=1 defaultVersion: '1', }); // users.controller.ts @Controller('users') export class UsersController { @Get() @Version('1') findAllV1() { return this.usersService.findAllLegacy(); } @Get() @Version('2') findAllV2() { return this.usersService.findAllWithPagination(); } @Get(':id') @Version(['1', '2']) // Available in both versions findOne(@Param('id') id: string) { return this.usersService.findOne(id); } }
URI Versioning: GET /v1/users → UsersController.findAllV1() GET /v2/users → UsersController.findAllV2() Header Versioning: GET /users X-API-Version: 1 → findAllV1() X-API-Version: 2 → findAllV2()