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¶
- Incoming request
- Global validation pipes validate body/params/query
- Controller-level pipes
- Method-level pipes (parameter-scoped pipes run per-parameter)
- 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