NestJSTypeScriptAPIValidationBackend

Pipes et Interceptors NestJS : validez et transformez vos requêtes

Découvrez comment utiliser Pipes et Interceptors NestJS pour valider, transformer et enrichir vos requêtes API avec une architecture propre.

AMAlexis Mouchon9 min de lecture

Pipes et Interceptors NestJS : validez et transformez vos requêtes

Quand on construit une API NestJS, on se retrouve vite à répéter les mêmes patterns : valider un DTO, transformer un ID en ObjectId, logger les temps de réponse, formater la sortie. Plutôt que de polluer vos contrôleurs avec cette logique, NestJS propose deux mécanismes puissants : les Pipes et les Interceptors. Bien utilisés, ils rendent votre code plus lisible, plus testable et surtout plus cohérent.

Dans cet article, je vous montre comment les utiliser efficacement dans un projet NestJS moderne, avec des exemples concrets tirés de projets que je déploie en production.

Pipes vs Interceptors : quelle différence ?

Avant de plonger dans le code, il faut bien comprendre la distinction :

La règle simple que j'applique : si vous voulez agir sur une valeur précise, c'est un Pipe. Si vous voulez agir sur la requête ou la réponse dans son ensemble, c'est un Interceptor.

Les Pipes en pratique

Validation automatique avec class-validator

Le cas d'usage le plus courant, c'est la validation des DTO entrants. NestJS inclut un ValidationPipe qui se marie parfaitement avec class-validator et class-transformer.

// 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,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

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

Avec cette configuration globale :

Combiné à un DTO propre, c'est redoutable :

// create-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email!: string;

  @IsString()
  @MinLength(8)
  password!: string;

  @IsString()
  firstName!: string;
}

Le controller n'a plus rien à faire : si la requête est invalide, NestJS renvoie une 400 avec les erreurs détaillées.

Créer un Pipe custom

Parfois, la validation par décorateurs ne suffit pas. Exemple classique : transformer un string en ObjectId MongoDB et rejeter les valeurs invalides.

// parse-object-id.pipe.ts
import {
  PipeTransform,
  Injectable,
  BadRequestException,
} from '@nestjs/common';
import { Types } from 'mongoose';

@Injectable()
export class ParseObjectIdPipe implements PipeTransform<string, Types.ObjectId> {
  transform(value: string): Types.ObjectId {
    if (!Types.ObjectId.isValid(value)) {
      throw new BadRequestException(`"${value}" n'est pas un ObjectId valide`);
    }
    return new Types.ObjectId(value);
  }
}

Utilisation dans un controller :

@Get(':id')
async findOne(@Param('id', ParseObjectIdPipe) id: Types.ObjectId) {
  return this.usersService.findById(id);
}

Résultat : votre service reçoit directement un ObjectId, jamais un string douteux. C'est du typage qui se propage jusqu'à la couche data.

Les Interceptors en pratique

Logger chaque requête avec son temps de réponse

Un Interceptor a accès au ExecutionContext et au CallHandler, ce qui permet de mesurer le temps d'exécution proprement :

// logging.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('HTTP');

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = context.switchToHttp().getRequest();
    const { method, url } = req;
    const start = Date.now();

    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - start;
        this.logger.log(`${method} ${url} - ${duration}ms`);
      }),
    );
  }
}

Enregistré globalement dans le main.ts avec app.useGlobalInterceptors(new LoggingInterceptor()), il logge toutes les requêtes. Sans toucher à aucun controller.

Formater la réponse API

Un pattern que j'utilise souvent : encapsuler toutes les réponses dans une structure standard { data, timestamp }. C'est un cas typique pour un Interceptor :

// transform.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface ApiResponse<T> {
  data: T;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, ApiResponse<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map((data) => ({
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

Le client reçoit alors systématiquement :

{
  "data": { "id": "abc", "email": "user@example.com" },
  "timestamp": "2026-04-23T08:00:00.000Z"
}

Pas besoin de s'en occuper dans chaque controller, la cohérence est garantie.

Gérer les timeouts

Autre usage pratique : rejeter automatiquement une requête qui met trop de temps, plutôt que de laisser le client poireauter.

// timeout.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { timeout, catchError } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      timeout(5000),
      catchError((err) => {
        if (err instanceof TimeoutError) {
          return throwError(() => new RequestTimeoutException());
        }
        return throwError(() => err);
      }),
    );
  }
}

Au-delà de 5 secondes, la requête est coupée avec une 408 propre. Utile sur des endpoints qui appellent des services externes potentiellement lents.

Quand combiner les deux ?

Un cas concret : une API qui reçoit des fichiers. Vous pouvez chaîner :

  1. Un Interceptor (FileInterceptor de @nestjs/platform-express) qui extrait le fichier du multipart.
  2. Un Pipe custom qui valide sa taille, son type MIME, et rejette si nécessaire.
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(
  @UploadedFile(new FileValidationPipe({ maxSize: 2_000_000 }))
  file: Express.Multer.File,
) {
  return this.usersService.saveAvatar(file);
}

Ce découpage garde chaque responsabilité isolée et chaque composant unitairement testable.

Ordre d'exécution : ne vous faites pas piéger

NestJS exécute les éléments dans un ordre précis, et c'est une source fréquente de bugs silencieux :

  1. Middleware (Express)
  2. Guards (authentification, permissions)
  3. Interceptors (avant le handler)
  4. Pipes (validation / transformation des arguments)
  5. Handler (votre méthode de controller)
  6. Interceptors (après le handler, via RxJS)
  7. Filters d'exception si une erreur remonte

Conséquence pratique : un Interceptor qui logge la requête entrante voit les données avant transformation par les Pipes. Si vous voulez logger le DTO validé, déplacez la logique dans la partie tap() après next.handle(), ou loggez depuis le service.

Portée : global, controller, ou handler ?

Pipes et Interceptors peuvent être enregistrés à trois niveaux :

Ma recommandation : la validation (ValidationPipe) et le logging vont en global. Les Pipes métier (ParseObjectIdPipe) au niveau du handler. Les Interceptors spécifiques (formatage d'une ressource particulière) au niveau du controller.

Conclusion

Pipes et Interceptors sont deux des briques les plus puissantes de NestJS. Les Pipes vous libèrent de la validation manuelle et sécurisent vos entrées ; les Interceptors vous permettent de traiter transversalement tout ce qui concerne le cycle de vie d'une requête. Ensemble, ils rendent votre API plus prévisible, votre code plus DRY et vos contrôleurs minimalistes — exactement ce qu'on attend d'une bonne architecture backend.

Si vous construisez une API NestJS et que vous peinez encore à structurer votre code de validation ou de logging, commencez par ces deux patterns : le gain est immédiat.


Vous avez un projet web avec une API NestJS à construire ou à refondre ? N'hésitez pas à me contacter pour en discuter, j'aide régulièrement des clients à poser des bases backend propres et scalables.