Validation và transform dữ liệu trong NestJS sử dụng Class Validator và Class Transformer

By Thái Nguyễn
Picture of the author
Published on
class validator and transformer nestjs image

Nội dung


Giới thiệu

Trong các ứng dụng web, việc xác thực dữ liệu đầu vào và chuyển đổi dữ liệu là hai nhiệm vụ quan trọng. NestJS cung cấp hai công cụ mạnh mẽ để thực hiện điều này:

  • Class Validator: Giúp xác thực dữ liệu đầu vào theo các quy tắc định sẵn
  • Class Transformer: Cho phép chuyển đổi dữ liệu giữa plain objects và class instances

Việc sử dụng chúng mang lại các lợi ích:

  • Đảm bảo tính toàn vẹn của dữ liệu
  • Tự động chuyển đổi kiểu dữ liệu
  • Code dễ đọc và bảo trì hơn
  • Tách biệt logic xử lý dữ liệu

Cài đặt

Trước tiên, bạn cần cài đặt các thư viện liên quan:

npm install class-validator class-transformer

Nếu bạn đang sử dụng @nestjs/common@nestjs/core phiên bản mới nhất, các thư viện này đã được tích hợp sẵn.


Class Validator

Trường hợp cơ bản với Class Validator

import { IsString, IsEmail, IsNotEmpty, MinLength, IsOptional, IsNumber, Min, Max } from 'class-validator';

// DTO để tạo mới user
export class CreateUserDto {
  // Validate tên người dùng:
  // - Phải là string
  // - Không được rỗng
  // - Độ dài tối thiểu 2 ký tự
  @IsString()
  @IsNotEmpty({ message: 'Tên người dùng không được để trống' })
  @MinLength(2, { message: 'Tên người dùng phải có ít nhất 2 ký tự' })
  name: string;

  // Validate email:
  // - Phải đúng định dạng email
  // - Không được rỗng
  @IsEmail({}, { message: 'Email không hợp lệ' })
  @IsNotEmpty({ message: 'Email không được để trống' })
  email: string;

  // Validate mật khẩu:
  // - Phải là string
  // - Độ dài tối thiểu 6 ký tự
  @IsString()
  @MinLength(6, { message: 'Mật khẩu phải có ít nhất 6 ký tự' })
  password: string;

  // Validate tuổi (không bắt buộc):
  // - Phải là số
  // - Giá trị từ 1-150
  @IsOptional() // Trường không bắt buộc
  @IsNumber({}, { message: 'Tuổi phải là số' })
  @Min(1, { message: 'Tuổi phải lớn hơn 0' })
  @Max(150, { message: 'Tuổi không hợp lệ' })
  age?: number;
}

Trường hợp nâng cao với Class Validator

  1. Validation với điều kiện phức tạp
import { IsString, ValidateIf, IsNotEmpty, IsEnum, IsArray, ArrayMinSize, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

// Enum định nghĩa các role có thể có
enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  MANAGER = 'manager'
}

// Class định nghĩa quyền hạn
class Permission {
  @IsString()
  @IsNotEmpty()
  resource: string; // Tên resource (vd: 'users', 'products')

  @IsArray()
  @IsString({ each: true })
  actions: string[]; // Các hành động được phép (vd: ['read', 'write'])
}

export class UpdateUserRoleDto {
  // Validate role phải thuộc enum UserRole
  @IsEnum(UserRole, { message: 'Role không hợp lệ' })
  role: UserRole;

  // adminCode chỉ bắt buộc khi role là ADMIN
  @ValidateIf(o => o.role === UserRole.ADMIN)
  @IsString()
  @IsNotEmpty({ message: 'Admin code bắt buộc cho role admin' })
  adminCode?: string;

  // departments chỉ bắt buộc khi role là MANAGER
  @ValidateIf(o => o.role === UserRole.MANAGER)
  @IsArray()
  @ArrayMinSize(1, { message: 'Manager cần ít nhất 1 department' })
  @IsString({ each: true })
  departments?: string[];

  // permissions chỉ áp dụng cho ADMIN và MANAGER
  @ValidateNested({ each: true })
  @Type(() => Permission)
  @ValidateIf(o => [UserRole.ADMIN, UserRole.MANAGER].includes(o.role))
  permissions?: Permission[];
}
  1. Custom Validation Decorator
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';

/**
 * Custom decorator để kiểm tra mật khẩu mạnh
 * Mật khẩu phải chứa:
 * - Ít nhất 1 chữ hoa
 * - Ít nhất 1 chữ thường
 * - Ít nhất 1 số
 * - Ít nhất 1 ký tự đặc biệt
 */
export function IsStrongPassword(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isStrongPassword',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const hasUpperCase = /[A-Z]/.test(value);
          const hasLowerCase = /[a-z]/.test(value);
          const hasNumbers = /\d/.test(value);
          const hasSpecialChar = /[!@#$%^&*]/.test(value);
          return hasUpperCase && hasLowerCase && hasNumbers && hasSpecialChar;
        },
        defaultMessage(args: ValidationArguments) {
          return 'Mật khẩu phải chứa chữ hoa, chữ thường, số và ký tự đặc biệt';
        },
      },
    });
  };
}

/**
 * Custom decorator để kiểm tra ngày trong tương lai
 * Sử dụng cho các trường như:
 * - Ngày hết hạn
 * - Ngày dự kiến giao hàng
 * - Ngày hẹn
 */
export function IsFutureDate(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isFutureDate',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any) {
          return value instanceof Date && value > new Date();
        },
        defaultMessage(args: ValidationArguments) {
          return `${args.property} phải là ngày trong tương lai`;
        },
      },
    });
  };
}

// Ví dụ sử dụng custom decorators
class EventDto {
  @IsString()
  title: string;

  @IsFutureDate({ message: 'Ngày tổ chức phải là ngày trong tương lai' })
  eventDate: Date;

  @IsStrongPassword({ message: 'Mật khẩu không đủ mạnh' })
  accessCode: string;
}
  1. Validation với Nested Objects và Arrays
import { Type } from 'class-transformer';
import { IsString, ValidateNested, IsArray, ArrayMinSize, IsNumber } from 'class-validator';

class AddressDto {
  @IsString()
  street: string;

  @IsString()
  city: string;

  @IsString()
  country: string;
}

class OrderItemDto {
  @IsString()
  productId: string;

  @IsNumber()
  quantity: number;

  @IsNumber()
  price: number;
}

export class CreateOrderDto {
  @IsString()
  userId: string;

  @ValidateNested()
  @Type(() => AddressDto)
  shippingAddress: AddressDto;

  @ValidateNested()
  @Type(() => AddressDto)
  billingAddress: AddressDto;

  @IsArray()
  @ValidateNested({ each: true })
  @ArrayMinSize(1, { message: 'Đơn hàng phải có ít nhất 1 sản phẩm' })
  @Type(() => OrderItemDto)
  items: OrderItemDto[];
}

Class Transformer

Trường hợp cơ bản với Class Transformer

import { Exclude, Expose, Transform } from 'class-transformer';

export class UserResponseDto {
  @Expose() // Cho phép hiển thị trường này trong response
  id: string;

  @Expose()
  name: string;

  @Expose()
  email: string;

  @Exclude() // Loại bỏ password khỏi response vì lý do bảo mật
  password: string;

  @Expose()
  @Transform(({ value }) => value ? new Date(value) : null)
  createdAt: Date; // Chuyển đổi string thành Date object

  @Expose()
  @Transform(({ value }) => value ? `${value}đ` : '0đ')
  balance: number; // Thêm đơn vị tiền tệ vào số dư

  @Expose()
  @Transform(({ obj }) => obj.isActive ? 'Đang hoạt động' : 'Không hoạt động')
  status: string; // Chuyển đổi boolean thành text dễ đọc
}

Trường hợp nâng cao với Class Transformer

  1. Transform với Logic Phức tạp
import { Expose, Transform, Type } from 'class-transformer';

class ProductDetailsDto {
  @Expose()
  name: string;

  // Transform giá sản phẩm, làm tròn đến 2 chữ số thập phân
  @Expose()
  @Transform(({ value }) => value.toFixed(2))
  price: number;

  // Transform trạng thái tồn kho thành text dễ đọc
  // - Nếu stock > 10: "Còn hàng"
  // - Nếu 0 < stock <= 10: "Sắp hết hàng"
  // - Nếu stock = 0: "Hết hàng"
  @Expose()
  @Transform(({ obj }) => {
    if (obj.stock > 10) return 'Còn hàng';
    if (obj.stock > 0) return 'Sắp hết hàng';
    return 'Hết hàng';
  })
  stockStatus: string;

  // Transform giá và tính toán chi tiết giảm giá
  // Trả về object chứa:
  // - originalPrice: Giá gốc
  // - discountPercent: Phần trăm giảm giá
  // - finalPrice: Giá sau giảm giá
  // - saving: Số tiền tiết kiệm được
  @Expose()
  @Transform(({ obj }) => {
    const discount = obj.discount || 0;
    const price = obj.price;
    return {
      originalPrice: price,
      discountPercent: discount,
      finalPrice: price * (1 - discount/100),
      saving: price * (discount/100)
    };
  })
  priceDetails: any;
}
  1. Transform với Groups và Versions
import { Expose, Transform, Type } from 'class-transformer';

export class UserProfileDto {
  // Basic fields - all versions
  @Expose({ groups: ['basic', 'admin'] })
  id: string;

  // Version 1: Sử dụng name
  @Expose({ groups: ['basic', 'admin'], toVersion: 1 })
  name: string;

  // Version 2: Tách thành firstName và lastName
  @Expose({ groups: ['basic', 'admin'], since: 2 })
  firstName: string;

  @Expose({ groups: ['basic', 'admin'], since: 2 })
  lastName: string;

  // Chỉ admin mới xem được email
  @Expose({ groups: ['admin'] })
  email: string;

  // Version 1: Format phone number bình thường
  @Expose({ groups: ['admin'], toVersion: 1 })
  @Transform(({ value }) => value?.toString())
  phoneNumber: string;

  // Version 2: Format phone number với mã quốc gia
  @Expose({ groups: ['admin'], since: 2 })
  @Transform(({ value }) => value ? `+84 ${value}` : null)
  phoneNumber: string;
}

// user-profile.controller.ts
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UserProfileController {
  constructor(private readonly userService: UserService) {}

  // API v1 cho user thường
  @Get('v1/:id')
  async getBasicProfileV1(@Param('id') id: string) {
    const user = await this.userService.findOne(id);
    return plainToClass(UserProfileDto, user, {
      groups: ['basic'],
      version: 1,
      excludeExtraneousValues: true
    });
  }

  // API v2 cho admin
  @Get('v2/:id/admin')
  async getAdminProfileV2(@Param('id') id: string) {
    const user = await this.userService.findOne(id);
    return plainToClass(UserProfileDto, user, {
      groups: ['basic', 'admin'],
      version: 2,
      excludeExtraneousValues: true
    });
  }
}

// Ví dụ response:

// 1. GET /v1/users/123 (Basic User - Version 1)
{
  "id": "123",
  "name": "John Doe"
}

// 2. GET /v2/users/123/admin (Admin - Version 2)
{
  "id": "123",
  "firstName": "John",
  "lastName": "Doe",
  "email": "john@example.com",
  "phoneNumber": "+84 123456789"
}

Lưu ý khi sử dụng Groups và Versions:

  1. Groups dùng để phân quyền truy cập data:
  • basic: Thông tin cơ bản mà ai cũng xem được
  • admin: Thông tin chỉ admin mới xem được
  1. Versions dùng để quản lý thay đổi API:
  • toVersion: Chỉ xuất hiện đến version nào đó
  • since: Bắt đầu xuất hiện từ version nào đó

Kết hợp Class Validator và Class Transformer

Ví dụ thực tế về một hệ thống đặt hàng:

import { IsString, IsNumber, Min, ValidateNested, IsArray, ArrayMinSize } from 'class-validator';
import { Expose, Transform, Type } from 'class-transformer';

// DTOs cho đầu vào
class OrderItemInputDto {
  @IsString()
  @Expose()
  productId: string;

  @IsNumber()
  @Min(1)
  @Expose()
  quantity: number;
}

class AddressInputDto {
  @IsString()
  @Expose()
  street: string;

  @IsString()
  @Expose()
  city: string;

  @IsString()
  @Expose()
  country: string;
}

export class CreateOrderInputDto {
  @IsString()
  @Expose()
  userId: string;

  @ValidateNested()
  @Type(() => AddressInputDto)
  @Expose()
  shippingAddress: AddressInputDto;

  @IsArray()
  @ArrayMinSize(1)
  @ValidateNested({ each: true })
  @Type(() => OrderItemInputDto)
  @Expose()
  items: OrderItemInputDto[];

  @IsString()
  @Expose()
  paymentMethod: string;
}

// DTOs cho đầu ra
class OrderItemOutputDto {
  @Expose()
  productId: string;

  @Expose()
  productName: string;

  @Expose()
  quantity: number;

  @Expose()
  @Transform(({ value }) => `${value.toFixed(2)}đ`)
  unitPrice: number;

  @Expose()
  @Transform(({ obj }) => `${(obj.quantity * obj.unitPrice).toFixed(2)}đ`)
  totalPrice: string;
}

class AddressOutputDto {
  @Expose()
  @Transform(({ obj }) => `${obj.street}, ${obj.city}, ${obj.country}`)
  fullAddress: string;
}

export class OrderOutputDto {
  @Expose()
  orderId: string;

  @Expose()
  @Transform(({ value }) => new Date(value).toLocaleString('vi-VN'))
  createdAt: string;

  @Expose()
  @ValidateNested()
  @Type(() => AddressOutputDto)
  shippingAddress: AddressOutputDto;

  @Expose()
  @ValidateNested({ each: true })
  @Type(() => OrderItemOutputDto)
  items: OrderItemOutputDto[];

  @Expose()
  @Transform(({ obj }) => {
    const total = obj.items.reduce((sum, item) => 
      sum + (item.quantity * item.unitPrice), 0);
    return `${total.toFixed(2)}đ`;
  })
  totalAmount: string;

  @Expose()
  @Transform(({ obj }) => {
    const status = obj.status;
    switch(status) {
      case 'PENDING': return 'Chờ xử lý';
      case 'CONFIRMED': return 'Đã xác nhận';
      case 'SHIPPING': return 'Đang giao hàng';
      case 'DELIVERED': return 'Đã giao hàng';
      default: return 'Không xác định';
    }
  })
  status: string;
}

Sử dụng trong controller:

plainToClass là một hàm quan trọng trong class-transformer, giúp chuyển đổi plain JavaScript objects thành class instances.

import { Body, Controller, Post, Get, Param, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common';
import { CreateOrderInputDto, OrderOutputDto } from './dto/order.dto';
import { plainToClass } from 'class-transformer';

@Controller('orders')
@UseInterceptors(ClassSerializerInterceptor)
export class OrdersController {
  constructor(private readonly orderService: OrderService) {}

  @Post()
  async createOrder(@Body() createOrderDto: CreateOrderInputDto) {
    const order = await this.orderService.create(createOrderDto);
    return plainToClass(OrderOutputDto, order, { excludeExtraneousValues: true });
  }

  @Get(':id')
  async getOrder(@Param('id') id: string) {
    const order = await this.orderService.findOne(id);
    return plainToClass(OrderOutputDto, order, { excludeExtraneousValues: true });
  }

  @Get()
  async getAllOrders() {
    const orders = await this.orderService.findAll();
    return orders.map(order => 
      plainToClass(OrderOutputDto, order, { excludeExtraneousValues: true })
    );
  }
}

Kích hoạt Validation toàn cục

NestJS cho phép bạn kích hoạt validation toàn cục để áp dụng tự động cho tất cả các DTO.

Trong file main.ts, thêm middleware ValidationPipe:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

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

  // Kích hoạt ValidationPipe
  app.useGlobalPipes(new ValidationPipe({
    transform: true,
    whitelist: true,
    forbidNonWhitelisted: true,
    transformOptions: {
      enableImplicitConversion: true,
      exposeDefaultValues: true
    }
  }));

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

Xử lý lỗi validation

NestJS sẽ tự động trả về lỗi validation nếu dữ liệu không hợp lệ. Bạn có thể tuỳ chỉnh thông báo lỗi và thêm xử lý như sau:

app.useGlobalPipes(new ValidationPipe({
  exceptionFactory: (errors) => {
    return new BadRequestException(
      errors.map(err => ({
        field: err.property,
        errors: Object.values(err.constraints),
      }))
    );
  },
}));

Kết quả trả về:

{
  "statusCode": 400,
  "message": [
    {
      "field": "name",
      "errors": ["Tên người dùng không được để trống"]
    }
  ]
}

Kết luận

Class Validator và Class Transformer là bộ đôi công cụ mạnh mẽ trong NestJS. Khi được sử dụng riêng lẻ, mỗi công cụ đều có những ưu điểm riêng, nhưng khi kết hợp chúng lại, bạn có thể:

  • Xác thực dữ liệu đầu vào một cách chặt chẽ
  • Tự động chuyển đổi kiểu dữ liệu
  • Kiểm soát dữ liệu đầu ra
  • Tạo ra API có tính nhất quán và dễ bảo trì

Việc hiểu và sử dụng hiệu quả cả hai công cụ này sẽ giúp bạn xây dựng các ứng dụng NestJS chất lượng và bảo mật hơn.

Hãy thử áp dụng ngay hôm nay để thấy sự khác biệt trong cách xử lý dữ liệu của bạn!