Dans toute application web, la validation des données entrantes est une étape critique. Accepter des données malformées, incomplètes ou malveillantes peut entraîner des bugs, des failles de sécurité et des corruptions de base de données. NestJS propose une approche élégante et déclarative pour résoudre ce problème grâce aux DTOs (Data Transfer Objects) combinés à la librairie class-validator.
Dans cet article, nous allons explorer en profondeur comment structurer, valider et transformer les données entrantes dans une application NestJS, en nous appuyant sur des exemples concrets issus d'un projet réel : une plateforme de réservation de services.
Qu'est-ce qu'un DTO et pourquoi l'utiliser ?
Un DTO (Data Transfer Object) est un objet dont le seul rôle est de transporter des données entre deux couches de l'application. Contrairement à une entité qui représente une table en base de données, le DTO définit la forme des données attendues à l'entrée (ou à la sortie) d'un endpoint.
Pourquoi ne pas utiliser directement l'entité TypeORM dans le contrôleur ? Pour plusieurs raisons :
- Séparation des responsabilités : l'entité gère la persistance, le DTO gère le contrat d'API.
- Sécurité : le DTO expose uniquement les champs que le client a le droit d'envoyer. Un utilisateur ne doit pas pouvoir définir son propre
idou sonrole. - Validation déclarative : grâce aux décorateurs, chaque champ du DTO porte ses propres règles de validation.
- Documentation automatique : combiné à
@nestjs/swagger, le DTO génère automatiquement la documentation OpenAPI.
En résumé, le DTO agit comme un gardien entre le monde extérieur et votre logique métier.
Installation de class-validator et class-transformer
Ces deux librairies fonctionnent en tandem. class-validator fournit les décorateurs de validation, tandis que class-transformer transforme les objets JSON bruts en instances de classes (ce qui permet aux décorateurs de fonctionner).
npm install class-validator class-transformer
Une fois installées, il faut activer le ValidationPipe globalement dans votre application. C'est ce pipe qui orchestre la transformation et la validation automatique de chaque requête entrante :
// 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, // Supprime les propriétés non décorées
forbidNonWhitelisted: true, // Rejette la requête si des propriétés inconnues sont envoyées
transform: true, // Transforme le payload en instance de la classe DTO
}),
);
await app.listen(3000);
}
bootstrap();
Détaillons ces trois options essentielles :
- whitelist: true — Toute propriété du body qui n'a pas de décorateur
class-validatordans le DTO sera automatiquement supprimée. C'est une protection contre l'injection de champs non prévus. - forbidNonWhitelisted: true — Va plus loin que
whitelist: au lieu de supprimer silencieusement les propriétés inconnues, le pipe retourne une erreur 400. Idéal en développement pour détecter les erreurs côté client. - transform: true — Convertit automatiquement le payload JSON en instance de la classe DTO. Sans cette option,
class-validatorne peut pas appliquer ses décorateurs.
Les décorateurs de validation fondamentaux
Voyons les décorateurs les plus courants à travers un exemple concret : le DTO d'inscription d'un utilisateur.
import { IsEmail, IsNotEmpty, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'admin@example.com' })
@IsEmail()
@IsNotEmpty()
@MaxLength(254)
email: string;
@ApiProperty({ example: 'password123' })
@IsString()
@IsNotEmpty()
@MinLength(6)
@MaxLength(128)
password: string;
@ApiProperty({ example: 'John' })
@IsString()
@IsNotEmpty()
@MaxLength(50)
firstName: string;
@ApiProperty({ example: 'Doe' })
@IsString()
@IsNotEmpty()
@MaxLength(50)
lastName: string;
@ApiProperty({ example: '+33612345678', required: false })
@IsString()
@IsOptional()
@MaxLength(20)
phone?: string;
}
Analysons chaque décorateur utilisé :
- @IsString() — Vérifie que la valeur est une chaîne de caractères. Rejette les nombres, booléens, objets, etc.
- @IsEmail() — Valide le format d'une adresse email selon la RFC.
- @IsNotEmpty() — Interdit les chaînes vides
"". Attention :@IsString()seul accepte une chaîne vide. - @MinLength(6) — Le mot de passe doit contenir au moins 6 caractères.
- @MaxLength(254) — Limite la longueur maximale. Essentiel pour correspondre aux contraintes de votre base de données.
- @IsOptional() — Le champ peut être absent ou
undefined. Si présent, les autres validations s'appliquent.
Validation avancée : tableaux, énumérations et UUID
Les applications réelles nécessitent des validations bien plus riches. Examinons le DTO de création d'un service, qui illustre plusieurs patterns avancés :
import {
IsString,
IsNotEmpty,
IsNumber,
IsArray,
ArrayMaxSize,
IsEnum,
IsOptional,
IsBoolean,
Min,
MaxLength,
} from 'class-validator';
import { ServiceType, ServiceCategory } from '../../../database/entities/guidance-service.entity';
export class CreateServiceDto {
@ApiProperty({ example: 'Guidance Téléphone' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
title: string;
@ApiProperty({ example: "L'échange en direct" })
@IsString()
@IsNotEmpty()
@MaxLength(200)
subtitle: string;
@ApiProperty({ example: 'Une consultation en direct par téléphone...' })
@IsString()
@IsNotEmpty()
@MaxLength(10000)
description: string;
@ApiProperty({ example: '30 à 45 minutes' })
@IsString()
@IsNotEmpty()
@MaxLength(50)
duration: string;
@ApiProperty({ example: 6000, description: 'Price in cents' })
@IsNumber()
@Min(0)
price: number;
@ApiProperty({ example: ['Connexion en direct', 'Questions illimitées'] })
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
@MaxLength(200, { each: true })
features: string[];
@ApiProperty({ enum: ServiceType, example: ServiceType.APPOINTMENT })
@IsEnum(ServiceType)
type: ServiceType;
@ApiProperty({ enum: ServiceCategory, example: ServiceCategory.TELEPHONE })
@IsEnum(ServiceCategory)
category: ServiceCategory;
@ApiProperty({ required: false, default: true })
@IsBoolean()
@IsOptional()
isActive?: boolean;
@ApiProperty({ required: false, default: 0 })
@IsNumber()
@IsOptional()
sortOrder?: number;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
@MaxLength(500)
imageUrl?: string;
}
Plusieurs points méritent une attention particulière :
Validation de nombres avec @IsNumber() et @Min()
Le champ price est stocké en centimes (6000 = 60,00 €), une bonne pratique pour éviter les erreurs d'arrondi avec les nombres flottants. Le décorateur @Min(0) garantit qu'on ne peut pas créer un service avec un prix négatif.
Validation de tableaux avec l'option { each: true }
Le champ features est un tableau de chaînes. L'option { each: true } est la clé : elle indique à class-validator d'appliquer le décorateur à chaque élément du tableau, et non au tableau lui-même. Ainsi, @IsString({ each: true }) vérifie que chaque feature est une chaîne, et @MaxLength(200, { each: true }) limite la longueur de chaque élément individuellement.
Validation d'énumérations avec @IsEnum()
Les champs type et category utilisent des enums TypeScript. Le décorateur @IsEnum(ServiceType) rejette automatiquement toute valeur qui ne fait pas partie de l'énumération. C'est bien plus robuste qu'un simple @IsString().
Validation d'UUID avec @IsUUID()
Dans le DTO de réservation, les identifiants sont des UUID v4 (et non des nombres auto-incrémentés) :
import {
IsString,
IsEmail,
IsOptional,
IsNotEmpty,
IsDateString,
IsUUID,
MaxLength,
} from 'class-validator';
export class CreateBookingDto {
@ApiProperty({ description: 'Service ID' })
@IsUUID()
@IsNotEmpty()
serviceId: string;
@ApiProperty({ description: 'User ID (if registered)', required: false })
@IsUUID()
@IsOptional()
userId?: string;
@ApiProperty({ description: 'Guest email', required: false })
@IsEmail()
@IsOptional()
@MaxLength(254)
guestEmail?: string;
@ApiProperty({ description: 'Booking date (for appointments)', required: false })
@IsDateString()
@IsOptional()
bookingDate?: string;
@ApiProperty({ description: 'Question(s) for written guidance', required: false })
@IsString()
@IsOptional()
@MaxLength(5000)
question?: string;
}
Le décorateur @IsUUID() vérifie que la chaîne respecte le format UUID. Le décorateur @IsDateString() valide qu'une chaîne est une date ISO 8601 valide (ex: "2025-03-15"). Notez aussi comment ce DTO mélange des champs obligatoires (serviceId) et optionnels — un pattern courant quand un même endpoint gère plusieurs scénarios (utilisateur connecté vs invité, rendez-vous vs guidance écrite).
Un autre exemple concis mais puissant est la validation d'un tableau d'UUID pour une opération de réordonnancement :
import { IsArray, IsUUID, ArrayMinSize } from 'class-validator';
export class ReorderDto {
@IsArray()
@IsUUID('4', { each: true })
@ArrayMinSize(1)
ids: string[];
}
Ici, @IsUUID('4', { each: true }) vérifie que chaque élément du tableau est un UUID version 4, et @ArrayMinSize(1) garantit qu'on envoie au moins un identifiant.
Messages d'erreur personnalisés
Par défaut, class-validator génère des messages en anglais assez techniques. Vous pouvez les personnaliser avec l'option message de chaque décorateur :
@IsString({ message: 'Le titre doit être une chaîne de caractères' })
@IsNotEmpty({ message: 'Le titre est obligatoire' })
@MaxLength(100, { message: 'Le titre ne peut pas dépasser $constraint1 caractères' })
title: string;
@IsNumber({}, { message: 'Le prix doit être un nombre' })
@Min(0, { message: 'Le prix ne peut pas être négatif' })
price: number;
@IsEnum(ServiceType, { message: 'Le type doit être l\'une des valeurs suivantes : $constraint2' })
type: ServiceType;
Les variables $constraint1, $constraint2, $value et $property sont automatiquement remplacées par les valeurs correspondantes. C'est particulièrement utile si votre API est consommée par un frontend qui affiche ces messages directement à l'utilisateur.
Validation conditionnelle avec @ValidateIf
Parfois, un champ ne doit être validé que sous certaines conditions. Le décorateur @ValidateIf permet d'exprimer cette logique :
import { ValidateIf, IsNotEmpty, IsDateString, IsString } from 'class-validator';
export class CreateBookingDto {
// bookingDate est obligatoire uniquement si c'est un rendez-vous
@ValidateIf((dto) => dto.type === 'appointment')
@IsDateString()
@IsNotEmpty()
bookingDate?: string;
// bookingTime est obligatoire uniquement si bookingDate est fourni
@ValidateIf((dto) => !!dto.bookingDate)
@IsString()
@IsNotEmpty()
@MaxLength(10)
bookingTime?: string;
// La question est obligatoire uniquement pour les guidances écrites
@ValidateIf((dto) => dto.type === 'written')
@IsString()
@IsNotEmpty()
@MaxLength(5000)
question?: string;
}
La fonction passée à @ValidateIf reçoit l'instance complète du DTO. Si elle retourne false, tous les décorateurs de validation sur ce champ sont ignorés. C'est un outil puissant pour gérer les formulaires dynamiques où les champs requis dépendent du contexte.
CreateDto vs UpdateDto : le pattern PartialType
Lors de la création d'une ressource, la plupart des champs sont obligatoires. Lors de la mise à jour, on veut pouvoir envoyer uniquement les champs modifiés (mise à jour partielle, ou PATCH). Réécrire un second DTO avec tous les champs en optionnel serait une violation flagrante du principe DRY.
NestJS résout ce problème avec la fonction utilitaire PartialType :
import { PartialType } from '@nestjs/swagger';
import { CreateServiceDto } from './create-service.dto';
export class UpdateServiceDto extends PartialType(CreateServiceDto) {}
C'est tout. PartialType crée une nouvelle classe où tous les champs de CreateServiceDto deviennent optionnels, tout en conservant leurs décorateurs de validation. Si vous envoyez un champ price dans votre PATCH, il sera toujours validé avec @IsNumber() et @Min(0). Mais son absence ne déclenchera pas d'erreur.
Attention : importezPartialTypedepuis@nestjs/swagger(et non depuis@nestjs/mapped-types) si vous utilisez Swagger. La version de@nestjs/swaggergère à la fois les métadonnées de validation ET les métadonnées Swagger, ce qui garantit que votre documentation OpenAPI reste synchronisée.
NestJS fournit d'autres utilitaires du même type :
- PickType(CreateServiceDto, ['title', 'price']) — Crée un DTO avec uniquement les champs sélectionnés.
- OmitType(CreateServiceDto, ['imageUrl']) — Crée un DTO sans les champs spécifiés.
- IntersectionType(DtoA, DtoB) — Fusionne deux DTOs en un seul.
Utilisation dans un contrôleur
Une fois le DTO défini et le ValidationPipe activé globalement, l'utilisation dans un contrôleur est transparente :
@Controller('services')
export class ServicesController {
constructor(private readonly servicesService: ServicesService) {}
@Post()
create(@Body() createServiceDto: CreateServiceDto) {
// À ce stade, createServiceDto est une instance validée de CreateServiceDto.
// Toutes les propriétés non décorées ont été supprimées (whitelist).
// Toutes les validations ont été vérifiées.
return this.servicesService.create(createServiceDto);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateServiceDto: UpdateServiceDto,
) {
// updateServiceDto peut contenir un sous-ensemble des champs.
return this.servicesService.update(id, updateServiceDto);
}
}
Si un client envoie des données invalides, NestJS retourne automatiquement une réponse 400 détaillée :
{
"statusCode": 400,
"message": [
"title must be shorter than or equal to 100 characters",
"price must not be less than 0",
"type must be a valid enum value"
],
"error": "Bad Request"
}
Bonnes pratiques et pièges à éviter
Voici les enseignements tirés de l'utilisation de class-validator en production :
- Toujours combiner @IsString() et @IsNotEmpty() —
@IsString()seul accepte les chaînes vides. Si le champ est requis, ajoutez systématiquement@IsNotEmpty(). - Définir des @MaxLength() cohérents avec votre schéma de base de données — Si votre colonne SQL est un
VARCHAR(100), votre DTO doit avoir@MaxLength(100). Sinon, vous obtiendrez des erreurs de troncature en base. - Stocker les prix en centimes — Utilisez
@IsNumber()avec@Min(0)et un type entier. Jamais de flottants pour les montants financiers. - Utiliser @IsUUID() pour les identifiants — Si vos entités utilisent des UUID (ce qui est recommandé), validez-les dans le DTO plutôt que de découvrir l'erreur au niveau de la base de données.
- Ne pas oublier { each: true } pour les tableaux — Sans cette option,
@IsString()validera le tableau lui-même (qui n'est pas une chaîne) et échouera systématiquement. - Organiser les DTOs par module — Chaque module NestJS devrait avoir son dossier
dto/contenant ses propres DTOs. Cela maintient la cohésion et facilite la navigation dans le code.
Conclusion
Les DTOs combinés à class-validator forment le premier rempart de votre application NestJS contre les données invalides. En quelques décorateurs, vous obtenez une validation robuste, des messages d'erreur explicites, une documentation Swagger automatique et une sécurité renforcée grâce au whitelist.
Les points essentiels à retenir :
- Activez le
ValidationPipeglobal avecwhitelist,forbidNonWhitelistedettransform. - Créez un
CreateDtoavec des validations strictes, puis dérivez l'UpdateDtoavecPartialType. - Alignez vos contraintes de validation (
@MaxLength,@Min) avec votre schéma de base de données. - Utilisez
@ValidateIfpour les validations conditionnelles complexes.
Pour aller plus loin, explorez les validateurs personnalisés (@ValidatorConstraint) pour créer vos propres décorateurs, et les groupes de validation pour appliquer des règles différentes selon le contexte. La documentation officielle de class-validator est une ressource précieuse pour découvrir l'ensemble des décorateurs disponibles.

Commentaires