Transformer les données avec class-transformer dans NestJS

Transformer les données avec class-transformer dans NestJS

Dans une API NestJS, les données qui transitent entre le client, le contrôleur et la base de données ne sont jamais identiques. Le client envoie des chaînes JSON, la base stocke des UUID et des dates, et certaines informations comme un mot de passe haché ne doivent jamais sortir du serveur. C'est précisément le rôle de class-transformer : transformer, sérialiser et filtrer les objets entre ces différentes couches.

Dans ce tutoriel, nous allons voir comment combiner class-transformer avec class-validator et le ValidationPipe de NestJS pour obtenir des DTO propres, sécurisés et fortement typés. Nous prendrons comme fil rouge l'entité User et ses champs sensibles.

Prérequis et installation

Pour suivre cet article, vous devez disposer d'un projet NestJS 10+ avec les packages suivants installés :

npm install class-validator class-transformer

Ces deux librairies sont en réalité indissociables dans l'écosystème NestJS : class-validator valide les données entrantes via des décorateurs (@IsString, @IsEmail, @MaxLength...), tandis que class-transformer convertit les objets bruts (JSON) en instances de classes typées, et inversement.

Activer la transformation globale avec ValidationPipe

Avant tout, il faut indiquer à NestJS qu'il doit non seulement valider les payloads entrants, mais aussi les transformer en instances de classes. Cela se configure dans main.ts :

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

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

  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,           // Active class-transformer
      whitelist: true,           // Supprime les propriétés non décorées
      forbidNonWhitelisted: true, // Lève une erreur si propriétés en trop
      transformOptions: {
        enableImplicitConversion: true, // Convertit string -> number, etc.
      },
    }),
  );

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

Avec transform: true, chaque DTO injecté via @Body(), @Query() ou @Param() est automatiquement instancié comme une vraie classe TypeScript. Vous pouvez donc appeler ses méthodes, et les décorateurs @Type ou @Transform seront appliqués.

@Transform : convertir les données entrantes

Le décorateur @Transform permet d'appliquer une fonction personnalisée sur une propriété au moment de la désérialisation. Cas typique : nettoyer un email reçu depuis un formulaire.

Reprenons un DTO inspiré de CreateBookingDto et ajoutons des transformations utiles :

import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';

export class CreateBookingDto {
  @ApiProperty({ description: 'Guest email', required: false })
  @Transform(({ value }) => value?.trim().toLowerCase())
  @IsEmail()
  @IsOptional()
  @MaxLength(254)
  guestEmail?: string;

  @ApiProperty({ description: 'Guest name', required: false })
  @Transform(({ value }) => value?.trim())
  @IsString()
  @IsOptional()
  @MaxLength(100)
  guestName?: string;
}

Avant même que class-validator ne contrôle l'email, la valeur est nettoyée : plus d'espaces parasites ni de différences de casse. C'est aussi le bon endroit pour convertir un booléen passé en query string :

@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
includeInactive?: boolean;

On retrouve d'ailleurs ce besoin dans le contrôleur ServicesController, où le query param includeInactive est comparé manuellement à la chaîne 'true'. Avec @Transform, on peut éviter cette gymnastique côté contrôleur.

@Type : objets imbriqués et dates

Lorsqu'un payload contient des objets imbriqués ou des dates, TypeScript ne suffit pas : à l'exécution, ces propriétés restent de simples objets ou strings. Le décorateur @Type indique à class-transformer la classe cible.

import { Type } from 'class-transformer';
import { IsDate, ValidateNested } from 'class-validator';

export class CreateBookingDto {
  @Type(() => Date)
  @IsDate()
  bookingDate?: Date;

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

Une date ISO envoyée par le client ("2026-05-12T10:00:00Z") devient ainsi un véritable objet Date dans votre service. Vous pouvez immédiatement appeler .getTime() ou la passer à TypeORM sans conversion supplémentaire. Notez la différence avec @IsDateString() utilisé dans CreateBookingDto : ce dernier valide le format mais conserve une string. Le couple @Type(() => Date) + @IsDate() garantit en plus la conversion.

Sécuriser les réponses : @Exclude, @Expose et ClassSerializerInterceptor

Le risque le plus courant dans une API utilisateurs est de retourner accidentellement le mot de passe haché ou un refresh token. class-transformer propose deux décorateurs complémentaires :

  • @Exclude() : la propriété ne sera jamais sérialisée dans la réponse JSON.
  • @Expose() : seules les propriétés explicitement exposées sortiront (mode strict).

Voici une entité User typique annotée pour ne jamais exposer les champs sensibles :

import { Exclude } from 'class-transformer';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

export enum UserRole {
  USER = 'user',
  ADMIN = 'admin',
}

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column({ name: 'first_name', length: 100 })
  firstName: string;

  @Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
  role: UserRole;

  @Exclude()
  @Column({ name: 'password_hash' })
  passwordHash: string;

  @Exclude()
  @Column({ name: 'refresh_token', nullable: true })
  refreshToken?: string;

  @Column({ name: 'created_at' })
  createdAt: Date;
}

Pour que ces décorateurs soient pris en compte au moment de la réponse HTTP, il faut activer ClassSerializerInterceptor, soit globalement, soit par contrôleur :

import { ClassSerializerInterceptor } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(
    new ClassSerializerInterceptor(app.get(Reflector)),
  );
  await app.listen(3000);
}

Désormais, tout contrôleur qui retourne une instance de User (directement ou via ApiResponseDto.success(user)) verra automatiquement passwordHash et refreshToken retirés de la sortie JSON. Important : l'interceptor n'agit que sur les instances de classe, pas sur les objets bruts. C'est pourquoi transform: true dans le ValidationPipe et le retour d'entités TypeORM (qui sont déjà des instances) sont essentiels.

Cas pratique : exposer uniquement certains champs

Parfois, on souhaite l'inverse : ne montrer que ce qui est explicitement marqué. C'est utile pour un DTO de réponse public, par exemple le profil affiché à un autre utilisateur :

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

@Exclude()
export class PublicUserDto {
  @Expose()
  id: string;

  @Expose()
  firstName: string;

  @Expose()
  role: UserRole;

  constructor(partial: Partial<PublicUserDto>) {
    Object.assign(this, partial);
  }
}

La classe est globalement exclue, et seuls les champs annotés @Expose() survivront à la sérialisation. Dans le service, on retourne simplement new PublicUserDto(user), et l'on est certain qu'aucun champ ajouté plus tard à l'entité ne fuitera par accident.

Intégration avec ApiResponseDto

Dans un contrôleur typique du projet, comme ServicesController, les réponses sont enveloppées dans un ApiResponseDto :

@Get(':id')
@Public()
async findOne(
  @Param('id', ParseUUIDPipe) id: string,
): Promise<ApiResponseDto<GuidanceService>> {
  const service = await this.servicesService.findOne(id);
  return ApiResponseDto.success(service, 'Service retrieved successfully');
}

Le ClassSerializerInterceptor traverse cette enveloppe et applique les règles @Exclude/@Expose sur l'objet service imbriqué, à condition qu'il s'agisse bien d'une instance d'entité retournée par TypeORM. C'est une combinaison particulièrement élégante : la structure de réponse reste uniforme, et la sécurité des champs sensibles est garantie au niveau de l'entité, pas du contrôleur.

Pièges à éviter

  • Ne pas oublier transform: true dans le ValidationPipe, sinon vos DTO restent des objets littéraux et @Transform/@Type ne s'exécutent pas.
  • Attention à plainToInstance : si vous instanciez manuellement, pensez à passer { excludeExtraneousValues: true } pour respecter le mode @Expose strict.
  • Les entités TypeORM doivent rester des instances : un JSON.parse(JSON.stringify(user)) casse l'effet de @Exclude.
  • Cohérence avec Swagger : un champ exclu côté réponse doit aussi être retiré du DTO Swagger pour éviter de mentir dans la documentation.

Conclusion

Couplé à class-validator et au ValidationPipe, class-transformer apporte une couche de sécurité et de robustesse essentielle aux applications NestJS. Avec quelques décorateurs bien placés, vous pouvez nettoyer les entrées (@Transform), typer fortement les payloads imbriqués (@Type), et surtout garantir qu'aucune donnée sensible comme un passwordHash ne quittera jamais votre serveur (@Exclude + ClassSerializerInterceptor).

Pour aller plus loin, explorez les groupes de sérialisation (groups) qui permettent d'exposer différents champs selon le contexte (admin vs utilisateur), ainsi que la documentation officielle de class-transformer pour découvrir des décorateurs avancés comme @TransformPlainToInstance ou @SerializeOptions.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *