Skip to content

nest_09_pipes - Pipes & Validation

Input validation is not optional - it's the wall between your app and everyone trying to break it Skipping validation means you trust your users , and trusting users is how you get curl -X POST -d '{"role":"admin"}' past your register endpoint at 3AM Pipes transform and validate data before it reaches your handler

what's in here

  • PipeTransform interface and execution context
  • Built-in pipes: ValidationPipe , ParseIntPipe , ParseUUIDPipe , ParseBoolPipe
  • DTO validation with class-validator
  • Custom validation pipes
  • Zod and Joi integration
  • Whitelisting and stripping unknown properties

the PipeTransform interface

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common'

@Injectable()
export class UppercasePipe implements PipeTransform {
  transform(value: string, metadata: ArgumentMetadata): string {
    return value.toUpperCase()
  }
}

transform() receives the value and metadata about where it came from (body , param , query) The return value replaces the original value in the handler Throw an exception to reject the input and return an error response

built-in validation pipes

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

@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(
    @Param('id', ParseUUIDPipe) id: string  // rejects non-UUID
  ) {
    return this.usersService.findOne(id)
  }

  @Get()
  findAll(
    @Query('page', ParseIntPipe) page: number,  // rejects non-numeric
    @Query('limit', new ParseIntPipe({ optional: true })) limit?: number
  ) {
    return this.usersService.findAll({ page, limit })
  }

  @Post()
  create(@Body('email', ParseEmailPipe) email: string) {
    return this.usersService.create({ email })
  }
}

ParseIntPipe throws 400 if the value can't be parsed as int ParseUUIDPipe throws 400 if the value isn't a valid UUID ParseEmailPipe - wait , that's not built-in. you need custom pipes for domain-specific validation

DTO validation with class-validator

npm install class-validator class-transformer
import { IsEmail, IsString, MinLength, MaxLength, Matches, IsOptional } from 'class-validator'

export class CreateUserDto {
  @IsEmail({}, { message: 'provide a valid email address' })
  email: string

  @IsString()
  @MinLength(8, { message: 'password must be at least 8 characters' })
  @MaxLength(64)
  @Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'password must have uppercase , lowercase , and a number'
  })
  password: string

  @IsOptional()
  @IsString()
  displayName?: string
}

Then enable the ValidationPipe globally:

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,                // strip properties not in the DTO
    forbidNonWhitelisted: true,     // throw error on unknown properties
    transform: true,                // transform payload to DTO instance
    transformOptions: {
      enableImplicitConversion: true  // auto-convert types (string "123" -> number 123)
    }
  })
)

When whitelist: true , any property NOT decorated in the DTO is stripped When forbidNonWhitelisted: true , extra properties cause a 400 error instead of silent stripping This prevents mass assignment attacks - a user can't add "role":"admin" to their registration payload because role isn't in the DTO

custom validation pipe

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'

@Injectable()
export class ParseEmailPipe implements PipeTransform<string, string> {
  transform(value: string, metadata: ArgumentMetadata): string {
    if (!value || typeof value !== 'string') {
      throw new BadRequestException('email is required')
    }

    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(value)) {
      throw new BadRequestException('invalid email format')
    }

    // normalize email (lowercase , trim)
    return value.toLowerCase().trim()
  }
}

// usage
@Post()
create(@Body('email', ParseEmailPipe) email: string) {}

Parameter-scoped pipe - validates a single parameter Returns the transformed value (normalized email in this case) Throws BadRequestException for invalid input , which Nest's exception filter catches

entity validation pipe (Zod integration)

import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'
import { z, ZodSchema } from 'zod'

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value)
    if (!result.success) {
      const errors = result.error.errors.map(e =>
        `${e.path.join('.')}: ${e.message}`
      )
      throw new BadRequestException(errors)
    }
    return result.data
  }
}

// usage
const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).regex(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/),
  displayName: z.string().optional()
})

@Post()
create(@Body(new ZodValidationPipe(createUserSchema)) body: any) {}

Zod is lighter than class-validator and doesn't need decorators Schema is plain data - define it once , use it for both server and client validation The pipe validates the entire body against the schema and returns typed data

Joi integration

import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'
import * as Joi from 'joi'

@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private schema: Joi.ObjectSchema) {}

  transform(value: unknown) {
    const { error, value: validatedValue } = this.schema.validate(value, {
      abortEarly: false,
      stripUnknown: true
    })

    if (error) {
      const messages = error.details.map(d => d.message)
      throw new BadRequestException(messages)
    }

    return validatedValue
  }
}

// usage
const schema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required()
})

@Post()
create(@Body(new JoiValidationPipe(schema)) body: any) {}

Joi is mature and has extensive documentation stripUnknown: true removes properties not in the schema - same as class-validator's whitelist

pipe execution order

  1. Incoming request
  2. Global validation pipes validate body/params/query
  3. Controller-level pipes
  4. Method-level pipes (parameter-scoped pipes run per-parameter)
  5. Route handler receives validated , transformed data

Pipes run after guards but before interceptors (pre-phase) This means auth checks happen before validation - no point validating input from unauthenticated users beyond what's needed for the auth check itself

security note: what to validate

export class CreateUserDto {
  @IsEmail()
  email: string

  @IsString()
  @MinLength(8)
  password: string

  // NOTE: no role field - prevents privilege escalation
  // NOTE: no isAdmin field - same reason
  // NOTE: no id field - server generates it

  @IsOptional()
  @IsString()
  @MaxLength(100)
  bio?: string
}

Only include fields the client should provide Never include role , isAdmin , isVerified , or any permission-related field in a DTO that users submit If the DTO doesn't have it , whitelist: true strips it

prerequisites

nest_08_interceptors - Interceptors


next -> nest_10_filters - Exception Filters