Pipes trong NestJS

Pipes là một công cụ mạnh mẽ để validatetransform data. Một pipe là một class được trang trí bằng @Injectable() decorator, implement interface PipeTransform. Pipes được thực thi trước khi data tới controller methods.

Khái Niệm Pipe

Pipe có hai use cases chính:
  1. Transformation - Transform input data thành một dạng mong muốn (e.g., string → number)
  2. Validation - Validate input data và throw exception nếu invalid
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed: value must be a number');
    }
    return val;
  }
}

Built-in Pipes

NestJS cung cấp sẵn các pipes hữu ích:
import {
  ParseIntPipe,
  ParseFloatPipe,
  ParseBoolPipe,
  ParseArrayPipe,
  ParseUUIDPipe,
  ParseEnumPipe,
  ParseDatePipe,
  ValidationPipe,
  DefaultValuePipe,
} from '@nestjs/common';

// ParseIntPipe - Convert string to number
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  return { id }; // id is number
}

// ParseBoolPipe - Convert string to boolean
@Get()
search(@Query('active', ParseBoolPipe) active: boolean) {
  return { active }; // active is boolean
}

// ParseArrayPipe - Convert string to array
@Post('tags')
addTags(@Body('tags', ParseArrayPipe) tags: string[]) {
  return { tags };
}

// ParseUUIDPipe - Validate UUID format
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
  return { id };
}

// ParseEnumPipe - Validate enum values
enum Role {
  ADMIN = 'admin',
  USER = 'user',
}

@Post()
create(@Body('role', new ParseEnumPipe(Role)) role: Role) {
  return { role };
}

// DefaultValuePipe - Provide default value
@Get()
list(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number) {
  return { page }; // page = 1 if not provided
}

Custom Pipes

1. Validation Pipe

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

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any) {
    if (!value) {
      throw new BadRequestException('Value is required');
    }
    if (typeof value !== 'string') {
      throw new BadRequestException('Value must be a string');
    }
    return value.trim();
  }
}

2. Transformation Pipe

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

@Injectable()
export class ToUpperCasePipe implements PipeTransform {
  transform(value: string): string {
    return value.toUpperCase();
  }
}

// Sử dụng
@Post()
create(@Body('name', ToUpperCasePipe) name: string) {
  return { name }; // name sẽ UPPERCASE
}

3. Parse Int Pipe (Custom)

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

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException(
        `Validation failed: "${value}" is not a valid number`,
      );
    }
    return val;
  }
}

4. Validation Pipe với Default Value

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

interface ValidatePipeOptions {
  defaultValue?: any;
  required?: boolean;
}

@Injectable()
export class ValidatePipe implements PipeTransform {
  constructor(private options: ValidatePipeOptions = {}) {}

  transform(value: any) {
    if (!value && this.options.defaultValue !== undefined) {
      return this.options.defaultValue;
    }

    if (!value && this.options.required) {
      throw new BadRequestException('This field is required');
    }

    return value;
  }
}

Validation with class-validator

Setup

npm install class-validator class-transformer

DTOs with Decorators

import {
  IsString,
  IsEmail,
  IsNumber,
  IsOptional,
  Min,
  Max,
  Length,
  Matches,
  IsEnum,
  IsDate,
  ValidateIf,
  ValidateNested,
  IsArray,
} from 'class-validator';
import { Type } from 'class-transformer';

enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  MODERATOR = 'moderator',
}

class AddressDto {
  @IsString()
  street: string;

  @IsString()
  city: string;

  @IsString()
  zipCode: string;
}

export class CreateUserDto {
  @IsString()
  @Length(3, 50)
  name: string;

  @IsEmail()
  email: string;

  @IsNumber()
  @Min(18)
  @Max(120)
  age: number;

  @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/)
  password: string;

  @IsOptional()
  @IsEnum(UserRole)
  role?: UserRole;

  @IsOptional()
  @IsDate()
  @Type(() => Date)
  dateOfBirth?: Date;

  @IsOptional()
  @ValidateNested()
  @Type(() => AddressDto)
  address?: AddressDto;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  tags?: string[];

  @ValidateIf((o) => o.role === UserRole.ADMIN)
  @IsString()
  adminCode?: string;
}

Global Validation Pipe

// main.ts
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, // Loại bỏ properties không được định nghĩa
      forbidNonWhitelisted: true, // Throw error nếu có unexpected properties
      transform: true, // Auto-transform primitives to their types
      transformOptions: {
        enableImplicitConversion: true,
      },
    }),
  );

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

Sử dụng với Controllers

@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    // createUserDto được validate tự động
    return this.usersService.create(createUserDto);
  }

  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto);
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }

  @Get()
  findAll(
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ) {
    return this.usersService.findAll(page, limit);
  }
}

Pipes at Different Scopes

Method-level

@Controller('users')
export class UsersController {
  @Post()
  create(@Body(ValidationPipe) createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
}

Global-level

// main.ts
app.useGlobalPipes(new ValidationPipe());
app.useGlobalPipes(new TransformPipe());

Module-level (Provider)

import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

Ví Dụ Thực Tế

1. Custom Validation Pipe

import { PipeTransform, Injectable, BadRequestException, ArgumentMetadata } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, metadata: ArgumentMetadata) {
    if (!metadata.type || metadata.type === 'custom') return value;
    
    const object = plainToInstance(metadata.type, value);
    const errors = await validate(object);

    if (errors.length > 0) {
      const formattedErrors = errors.reduce((acc, error) => {
        acc[error.property] = Object.values(error.constraints || {});
        return acc;
      }, {});
      throw new BadRequestException({
        message: 'Validation failed',
        errors: formattedErrors,
      });
    }

    return object;
  }
}

2. Parse Comma-Separated List Pipe

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

@Injectable()
export class ParseCSVPipe implements PipeTransform<string, string[]> {
  transform(value: string): string[] {
    if (!value) {
      throw new BadRequestException('Value is required');
    }

    return value
      .split(',')
      .map((item) => item.trim())
      .filter((item) => item.length > 0);
  }
}

// Sử dụng
@Get('search')
search(@Query('tags', ParseCSVPipe) tags: string[]) {
  return { tags }; // tags = ['javascript', 'nodejs', 'nestjs']
}

3. Parse JSON Pipe

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

@Injectable()
export class ParseJSONPipe implements PipeTransform<string, object> {
  transform(value: string): object {
    try {
      return JSON.parse(value);
    } catch (error) {
      throw new BadRequestException('Invalid JSON format');
    }
  }
}

// Sử dụng
@Post('data')
processData(@Body('metadata', ParseJSONPipe) metadata: object) {
  return { metadata };
}

4. Sanitize HTML Pipe

import { PipeTransform, Injectable } from '@nestjs/common';
import * as sanitizeHtml from 'sanitize-html';

@Injectable()
export class SanitizeHtmlPipe implements PipeTransform<string, string> {
  transform(value: string): string {
    return sanitizeHtml(value, {
      allowedTags: ['b', 'i', 'em', 'strong', 'a'],
      allowedAttributes: {
        a: ['href'],
      },
    });
  }
}

// Sử dụng
@Post()
create(@Body('content', SanitizeHtmlPipe) content: string) {
  return { content }; // HTML sạch, an toàn
}

5. Trim String Pipe

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

@Injectable()
export class TrimPipe implements PipeTransform<string, string> {
  transform(value: string): string {
    return typeof value === 'string' ? value.trim() : value;
  }
}

// Sử dụng
@Post()
create(
  @Body('name', TrimPipe) name: string,
  @Body('email', TrimPipe) email: string,
) {
  return { name, email };
}

6. Lowercase Pipe

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

@Injectable()
export class LowercasePipe implements PipeTransform<string, string> {
  transform(value: string): string {
    return typeof value === 'string' ? value.toLowerCase() : value;
  }
}

7. Custom Enum Validation Pipe

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

enum Status {
  ACTIVE = 'active',
  INACTIVE = 'inactive',
  PENDING = 'pending',
}

@Injectable()
export class ValidateStatusPipe implements PipeTransform {
  transform(value: string): Status {
    const validStatuses = Object.values(Status);

    if (!validStatuses.includes(value as Status)) {
      throw new BadRequestException(
        `Invalid status. Must be one of: ${validStatuses.join(', ')}`,
      );
    }

    return value as Status;
  }
}

// Sử dụng
@Patch(':id/status')
updateStatus(
  @Param('id', ParseIntPipe) id: number,
  @Body('status', ValidateStatusPipe) status: Status,
) {
  return { id, status };
}

8. File Upload Validation Pipe

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

interface FileValidationOptions {
  maxSize?: number; // bytes
  allowedMimes?: string[];
}

@Injectable()
export class FileValidationPipe implements PipeTransform {
  constructor(private options: FileValidationOptions = {}) {}

  transform(file: Express.Multer.File) {
    if (!file) {
      throw new BadRequestException('File is required');
    }

    const { maxSize = 5 * 1024 * 1024, allowedMimes = ['image/jpeg', 'image/png'] } = this.options;

    if (file.size > maxSize) {
      throw new BadRequestException(
        `File size must be less than ${maxSize / 1024 / 1024}MB`,
      );
    }

    if (!allowedMimes.includes(file.mimetype)) {
      throw new BadRequestException(
        `File type must be one of: ${allowedMimes.join(', ')}`,
      );
    }

    return file;
  }
}

// Sử dụng
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile(FileValidationPipe) file: Express.Multer.File) {
  return { filename: file.filename };
}

9. Date Parsing Pipe

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

@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
  transform(value: string): Date {
    const date = new Date(value);

    if (isNaN(date.getTime())) {
      throw new BadRequestException(`Invalid date format: ${value}`);
    }

    return date;
  }
}

// Sử dụng
@Get('range')
getByDateRange(
  @Query('from', ParseDatePipe) from: Date,
  @Query('to', ParseDatePipe) to: Date,
) {
  return { from, to };
}

Validation Decorators

Built-in Decorators

// String validators
@IsString()
@IsEmail()
@IsUrl()
@IsIP()
@IsPhoneNumber()
@Length(min, max)
@MinLength(length)
@MaxLength(length)
@Matches(regex)

// Number validators
@IsNumber()
@IsInt()
@Min(value)
@Max(value)
@IsPositive()
@IsNegative()

// Boolean validators
@IsBoolean()

// Date validators
@IsDate()
@IsFuture()
@IsPast()

// Array validators
@IsArray()
@ArrayMinSize(min)
@ArrayMaxSize(max)
@ArrayContains(values)
@ArrayNotContains(values)

// Common validators
@IsOptional()
@IsEmpty()
@IsDefined()
@IsNotEmpty()
@IsEnum(enum)
@ValidateIf(condition)
@ValidateNested()
@Type(() => SomeClass)

Best Practices

1. Reusable DTOs

// base.dto.ts
export class BaseDto {
  @IsOptional()
  @IsDate()
  @Type(() => Date)
  createdAt?: Date;

  @IsOptional()
  @IsDate()
  @Type(() => Date)
  updatedAt?: Date;
}

// create-user.dto.ts
export class CreateUserDto extends BaseDto {
  @IsString()
  @Length(3, 50)
  name: string;

  @IsEmail()
  email: string;
}

// update-user.dto.ts
export class UpdateUserDto extends PartialType(CreateUserDto) {}

2. Nested Validation

class AddressDto {
  @IsString()
  street: string;

  @IsString()
  city: string;
}

class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

3. Conditional Validation

enum UserType {
  INDIVIDUAL = 'individual',
  BUSINESS = 'business',
}

export class CreateUserDto {
  @IsEnum(UserType)
  type: UserType;

  @ValidateIf((o) => o.type === UserType.BUSINESS)
  @IsString()
  companyName?: string;

  @ValidateIf((o) => o.type === UserType.INDIVIDUAL)
  @IsDate()
  dateOfBirth?: Date;
}

4. Custom Validation Decorators

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

// Decorator kiểm tra email duy nhất
export function IsUniqueEmail(validationOptions?: ValidationOptions) {
  return function (target: Object, propertyName: string) {
    registerDecorator({
      target: target.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        async validate(value: any, args: ValidationArguments) {
          const userRepository = // Get repository
          const user = await userRepository.findOne({ email: value });
          return !user;
        },
        defaultMessage(args: ValidationArguments) {
          return `Email ${args.value} already exists`;
        },
      },
    });
  };
}

// Sử dụng
export class CreateUserDto {
  @IsEmail()
  @IsUniqueEmail()
  email: string;
}

5. Validation Groups

import { ValidationPipe } from '@nestjs/common';

// Riêng biệt validation cho create và update
@Post()
create(
  @Body(new ValidationPipe({ groups: ['create'] }))
  createUserDto: CreateUserDto,
) {
  return this.usersService.create(createUserDto);
}

@Put(':id')
update(
  @Body(new ValidationPipe({ groups: ['update'] }))
  updateUserDto: UpdateUserDto,
) {
  return this.usersService.update(updateUserDto);
}

Complete Example

// dtos/create-user.dto.ts
import { IsString, IsEmail, IsNumber, Min, Max, Length, ValidateNested, Type } from 'class-validator';

class AddressDto {
  @IsString()
  street: string;

  @IsString()
  city: string;

  @IsString()
  zipCode: string;
}

export class CreateUserDto {
  @IsString()
  @Length(3, 50, { message: 'Name must be between 3 and 50 characters' })
  name: string;

  @IsEmail({}, { message: 'Invalid email format' })
  email: string;

  @IsNumber()
  @Min(18, { message: 'Age must be at least 18' })
  @Max(120, { message: 'Age must be at most 120' })
  age: number;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

// pipes/custom-validation.pipe.ts
import { PipeTransform, Injectable, BadRequestException, ArgumentMetadata } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

@Injectable()
export class CustomValidationPipe implements PipeTransform<any> {
  async transform(value: any, metadata: ArgumentMetadata) {
    if (!metadata.type || metadata.type === 'custom') return value;

    const object = plainToInstance(metadata.type, value);
    const errors = await validate(object);

    if (errors.length > 0) {
      const formattedErrors = errors.reduce((acc, error) => {
        acc[error.property] = Object.values(error.constraints || {});
        return acc;
      }, {});

      throw new BadRequestException({
        statusCode: 400,
        message: 'Validation failed',
        errors: formattedErrors,
        timestamp: new Date().toISOString(),
      });
    }

    return object;
  }
}

// users.controller.ts
@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Post()
  create(@Body(CustomValidationPipe) createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }

  @Get()
  findAll(
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ) {
    return this.usersService.findAll(page, limit);
  }
}

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

  app.useGlobalPipes(
    new CustomValidationPipe(),
  );

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

Kết Luận

Pipes là công cụ quan trọng để:
  • Validate input data trước khi tới logic xử lý
  • Transform data thành dạng mong muốn
  • Đảm bảo type safety
  • Tạo consistent error responses
  • Giảm boilerplate code trong controllers
Sử dụng pipes đúng cách giúp bạn:
  • Xây dựng ứng dụng robust và secure
  • Validate data một cách centralized
  • Cải thiện code quality
  • Giảm bugs liên quan đến invalid data