Custom Decorators
TL;DR — Trois familles : (1) param decorators via
createParamDecorator((data, ctx) => ...)pour extraire de la donnée duExecutionContext; (2) method/class decorators pour poser des métadonnées viaSetMetadata()lues par un Guard/Interceptor avecReflector; (3) composed decorators viaapplyDecorators()pour packager plusieurs annotations en une. Les bons décorateurs custom sont déclaratifs, testables, et n'embarquent pas de logique DB.
🧠 Mental model
write side read side
────────── ─────────
@SetMetadata('k',v) ────► Reflect.metadata Reflector.get('k', target)
@createParamDecorator((d,ctx) => extract) ────► injected dans le handler comme un Param
@applyDecorators(@A(), @B()) = @A() + @B() appliqués au même targetAnalogie — Les param decorators sont des mini-fonctions qui voient passer la requête et te servent un slice typé (@CurrentUser() te donne le user déjà parsed). Les class/method decorators sont des tags : ils ne font rien tout seuls, ils sont lus par un Guard/Interceptor pour décider quoi faire.
🛠️ Code minimal
// 1) Param decorator — @CurrentUser()
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthUser { id: string; roles: string[]; tenantId: string }
export const CurrentUser = createParamDecorator(
(data: keyof AuthUser | undefined, ctx: ExecutionContext): AuthUser | AuthUser[keyof AuthUser] => {
const req = ctx.switchToHttp().getRequest();
const user: AuthUser = req.user;
return data ? user?.[data] : user;
},
);
// Usage
@Get('me')
me(@CurrentUser() user: AuthUser) { return user; }
@Get('my-tenant')
tenant(@CurrentUser('tenantId') tenantId: string) { return tenantId; }// 2) @TenantId() — extrait du subdomain ou du header
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
export const TenantId = createParamDecorator((_d: unknown, ctx: ExecutionContext): string => {
const req = ctx.switchToHttp().getRequest();
const t = req.tenant?.id ?? (req.headers['x-tenant'] as string | undefined);
if (!t) throw new BadRequestException('missing_tenant');
return t;
});// 3) @RealIp() — résolution X-Forwarded-For sécurisée
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const RealIp = createParamDecorator((_d: unknown, ctx: ExecutionContext): string => {
const req = ctx.switchToHttp().getRequest();
// suppose app.set('trust proxy', ...) configuré → req.ip est déjà nettoyé par Express.
// Sans 'trust proxy', NE PAS lire x-forwarded-for brut (spoofable) : retomber sur l'IP socket.
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
});// 4) Method decorator — @Roles() (vu en 02-guards.md)
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);// 5) Composed — @Auth() = JwtAuthGuard + RolesGuard + @ApiBearerAuth() + @Roles()
import { applyDecorators, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata(ROLES_KEY, roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'unauthorized' }),
);
}
// Usage très concis
@Controller('admin')
export class AdminController {
@Auth('admin') @Get('users') list() { /* ... */ }
}// 6) Param decorator avec pipe
@Get(':id')
get(@Param('id', new ParseUUIDPipe()) id: string,
@CurrentUser('id') userId: string) { ... }
// Et même avec un pipe sur un decorator custom
@Get('search')
search(@CurrentUser('tenantId', new TenantValidationPipe()) tenantId: string) { ... }🎯 Patterns courants
@CurrentUser()— incontournable. Variante :@CurrentUser('id')pour ne pas re-déstructurer chaque fois.@TenantId()— multi-tenant : extrait du token / subdomain / header. Toujours throw si absent (le Guard a déjà validé l'auth).@RealIp()— résout l'IP réelle derrière un proxy (CloudFront, NGINX). Combiné avecapp.set('trust proxy', 'loopback,linklocal,uniquelocal')côté Express.@Public(),@Roles(),@RequirePermissions(...)— markers lus par les Guards viaReflector.@Auth(...roles)— composé viaapplyDecoratorspour packager Guards + metadata + Swagger en une annotation.@ApiPaginated(),@ApiOkResponse({...})— composer Swagger pour éviter la duplication sur chaque endpoint.@Throttle({ default: { limit, ttl } })— déjà fourni par@nestjs/throttler, exemple canonique de metadata decorator.@IdempotencyKey()— extrait le headerIdempotency-Key, valide le format, attache àreq.idempotencyKey. L'Interceptor de cache lit ensuite la valeur.@Cookies('session')— accès direct à un cookie typé. Préférer àreq.cookies.session.@RequestContext()— pour les bus de messages / RPC : extrait{ user, tenant, requestId, traceId }du contexte, à passer aux services pour observabilité.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
| Version | Notes |
|---|---|
| Nest 7 | createParamDecorator((data, req) => ...) (signature avec req direct, héritage Nest 6). |
| Nest 8 | Signature stabilisée : (data, ctx: ExecutionContext) => .... |
| Nest 9 | applyDecorators typage amélioré. Reflector.getAllAndOverride / getAllAndMerge stables. |
| Nest 10 | TypeScript 5 supporté. Le système de décorateurs reste legacy (experimentalDecorators: true) — pas de migration vers les décorateurs ES proposal. |
| Nest 11 | Toujours legacy decorators. Node 20+. Reflect.metadata requis (reflect-metadata import top-level dans main.ts). |
Note importante — Nest reste sur experimentalDecorators + emitDecoratorMetadata. Ne pas activer useDefineForClassFields: true sans précaution (peut casser l'injection de champs avec valeur par défaut).
🧭 Comment un staff engineer raisonne
Le piège mental du débutant : voir le décorateur comme « de la magie ». Le décorateur n'est rien d'autre qu'une fonction exécutée une seule fois, au chargement de la classe (pas par requête). Garde ce modèle en tête, et 90 % des bugs disparaissent.
// Ceci s'exécute UNE fois, au moment où Nest charge la classe :
export function Auth(...roles: AppRole[]) {
console.log('factory appelée'); // ← une fois par usage de @Auth dans le code
return applyDecorators(/* ... */);
}
// Ceci s'exécute À CHAQUE requête (c'est la closure d'extraction) :
export const CurrentUser = createParamDecorator((data, ctx) => {
// ce corps tourne par requête — il doit rester O(1), sync, sans I/O
});Deux temporalités, deux contrats. La factory (Auth, AuditTrail) tourne au boot → tu peux y faire des calculs, valider la config, throw tôt. La closure du param decorator tourne par requête → elle doit être synchrone, sans I/O, sans allocation lourde. Confondre les deux est la source n°1 des bugs de perf et de fuite d'état.
Métadonnée = donnée statique, pas comportement. Un metadata decorator ne fait rien : il dépose une étiquette lue plus tard par un Guard/Interceptor. Le couple @SetMetadata (write) / Reflector (read) est un bus de configuration déclaratif entre la couche route et la couche cross-cutting. Le corollaire architectural : un décorateur ne doit jamais contenir de logique métier — il route l'intention vers le bon consommateur.
Param decorator vs Guard vs Interceptor vs Pipe — qui fait quoi
| Besoin | Mécanisme | Pourquoi |
|---|---|---|
| Extraire un slice typé du contexte (user, tenant, IP) | Param decorator | Sync, déclaratif, testable en isolation |
| Décider d'autoriser/refuser (true/false) | Guard | A accès à la DI, court-circuite avant le handler |
| Charger une entité depuis un id (I/O) | Pipe ou Interceptor | DI disponible, async, peut throw 404 proprement |
| Transformer/wrapper la réponse, mesurer, logguer | Interceptor | Voit le flux RxJS avant et après le handler |
Poser une option lue ailleurs (@Roles, @CacheTTL) | Metadata decorator | Configuration statique consommée par Guard/Interceptor |
Règle de décision en une phrase : « est-ce que j'ai besoin de DI, d'async, ou d'un accès au flux de réponse ? » → si oui, ce n'est pas un param decorator, c'est un Guard/Pipe/Interceptor. Le param decorator est un lecteur passif et synchrone du contexte.
Le problème de la DI : pourquoi un param decorator ne peut pas injecter
createParamDecorator produit une closure pure, instanciée par le runtime de Nest hors du conteneur DI. Elle n'a donc pas accès aux providers. Le pattern canonique pour contourner :
// 1) Un Interceptor (lui, est dans la DI) enrichit la requête
@Injectable()
export class TenantResolverInterceptor implements NestInterceptor {
constructor(private readonly tenants: TenantService) {} // DI OK ici
async intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
req.tenant = await this.tenants.resolve(req.headers['x-tenant']); // I/O ici, pas dans le decorator
return next.handle();
}
}
// 2) Le param decorator se contente de LIRE ce qui a été déposé — sync, O(1)
export const Tenant = createParamDecorator((_d, ctx: ExecutionContext) =>
ctx.switchToHttp().getRequest().tenant,
);Mental model : l'Interceptor écrit, le décorateur lit. Le décorateur reste « bête » et donc trivialement testable.
Sous le capot : ce que createParamDecorator génère vraiment
Démystifier la « magie » verrouille le modèle mental. createParamDecorator(factory) ne fait pas de l'introspection runtime mystérieuse : il enregistre la closure dans la metadata du paramètre (ROUTE_ARGS_METADATA) au moment où la classe est chargée. À la résolution de la route, Nest itère sur les paramètres décorés, appelle ta factory avec (data, ctx), et injecte la valeur retournée à la bonne position d'argument du handler.
// Pseudo-code de ce qui se passe à l'exécution du handler :
const args: unknown[] = [];
for (const paramMeta of routeArgs) {
if (paramMeta.type === 'custom') {
let value = paramMeta.factory(paramMeta.data, ctx); // ← TA closure, par requête
for (const pipe of paramMeta.pipes) value = await pipe.transform(value, metadata); // pipes APRÈS
args[paramMeta.index] = value;
}
}
return handler.apply(controllerInstance, args);Trois conséquences que connaît un senior :
- Le pipe tourne après ta closure.
@CurrentUser('id', ParseUUIDPipe): la closure extraituser.id, puis le pipe le valide. C'est pourquoi un param decorator peut composer avec des pipes natifs — ils sont chaînés, pas concurrents. - La closure est appelée une fois par paramètre décoré, par requête. Deux
@CurrentUser()dans la même signature = deux appels. D'où l'impératif O(1) : pas deJSON.parse, pas de regex lourde, pas d'allocation par appel. dataest figé au boot. L'argument passé ('id','tenantId') est capturé une fois à l'évaluation du décorateur — ce n'est pas dynamique par requête. Le seul input vivant par requête est lectx.
Ordre d'exécution des enhancers — où se place le décorateur dans le pipeline
Un piège fréquent : croire qu'un param decorator « voit » l'auth. Faux. La chaîne par requête est Middleware → Guards → Interceptors (pré) → Pipes → param decorators → Handler → Interceptors (post). Le param decorator tourne après les Guards mais après aussi l'enrichissement fait par un Interceptor pré-handler. Corollaire : @CurrentUser() ne peut lire req.user que parce qu'un Guard (ou middleware Passport) l'a déjà posé avant. Si tu mets l'auth dans un Interceptor post-handler, req.user sera undefined au moment du décorateur — bug classique de timing.
getAllAndOverride vs getAllAndMerge — la sémantique compte
Quand une metadata existe au niveau classe et méthode, le Reflector offre deux résolutions. Choisir la mauvaise est un bug de sécurité silencieux.
// OVERRIDE : la méthode gagne sur la classe. Sémantique « le plus spécifique l'emporte ».
// Usage type : @Roles — un @Roles('admin') sur la méthode REMPLACE le @Roles('user') de la classe.
const roles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
ctx.getHandler(), // méthode d'abord (priorité haute)
ctx.getClass(), // puis classe
]);
// MERGE : on accumule. Sémantique « union ». Usage type : permissions cumulatives, tags.
const perms = this.reflector.getAllAndMerge<string[]>(PERMS_KEY, [
ctx.getHandler(),
ctx.getClass(),
]);| Méthode | Résolution | Quand l'utiliser |
|---|---|---|
get | une seule cible | metadata posée à un seul endroit |
getAllAndOverride | premier non-undefined gagne | override (rôles, @Public, cache TTL) |
getAllAndMerge | concat/merge de toutes les cibles | accumulation (permissions, feature flags) |
Failure mode classique : un @Public() sur une méthode d'un controller globalement protégé par un Guard. Si le Guard fait get(IS_PUBLIC, getHandler()) seulement, un @Public() posé au niveau classe est ignoré. Toujours getAllAndOverride([getHandler(), getClass()]).
Production : observabilité, sécurité, perf
- Perf — la closure d'un param decorator tourne par requête et par paramètre décoré. Sur un handler hot path à 50k req/s, un
split(',').map(...).filter(...)par paramètre se voit dans les flamegraphs. Garder O(1). Pas de regex coûteuse, pas deJSON.parse. - Sécurité — un
@RealIp()qui lit naïvementx-forwarded-forest spoofable : tout client peut envoyer ce header. Il ne faut le croire que derrière un proxy de confiance (app.set('trust proxy', ...)qui fait quereq.ipest déjà nettoyé par Express). Ne jamais utiliser une IP issue d'un header brut pour du rate-limiting ou de l'allow-list. - Sécurité metadata — un metadata decorator est de la donnée publique dans ton bundle. Ne jamais y stocker un secret, une clé, un seuil de fraude. C'est de la configuration de routing, pas un coffre.
- Observabilité — un
@RequestContext()qui extrait{ requestId, traceId, tenantId }et le passe aux services est le socle d'un tracing propre (OpenTelemetry). Le décorateur n'émet pas le span lui-même (ça, c'est l'Interceptor) ; il fournit le contexte corrélé. - Multi-transport —
ctx.switchToHttp()jette en WS/RPC. Un décorateur censé survivre à plusieurs transports doit brancher surctx.getType()(voir l'exemple end-to-end). Sinon il casse dès qu'on le réutilise dans un Gateway WebSocket.
⚠️ Pitfalls
createParamDecoratorqui accède à la DB — anti-pattern. Un param decorator doit être sync ou très léger. Pour fetch une entité, utiliser un Interceptor + Pipe ou un service injecté dans le handler.- DI dans un param decorator — pas possible directement. Workaround : passer par un Interceptor qui injecte la donnée dans
req, puis le decorator litreq. - Mauvais transport —
ctx.switchToHttp()plante en WS / RPC. Pour un decorator multi-transport :if (ctx.getType() === 'http') ... else if (ctx.getType() === 'ws') .... applyDecoratorsorder — les décorateurs sont appliqués bottom-up comme en TS standard. L'ordre dansapplyDecorators(A, B, C)correspond à@A @B @C(top à bottom dans le code source).SetMetadatasur class + method — le metadata est posé séparément. Le Guard doit utilisergetAllAndOverride([handler, class])ougetAllAndMergeselon la sémantique voulue.- Param decorator avec types complexes non sérialisables — si Nest active class-validator/transformer dessus, ça peut surprendre. En général, sortir un type primitif ou un objet simple.
- Reflection cassée — si tu utilises
swc/esbuildmal configuré,emitDecoratorMetadatapeut ne pas marcher ⇒Reflect.getMetadataretourneundefined. Vérifier la config. @CurrentUser()qui throw quand user absent — confus. Préférer retournernullsi endpoint@Public(), le handler décide.- Decorator avec un état partagé — déclarer un cache module-level dans le fichier du decorator ⇒ partagé entre toutes les requêtes. Anti-pattern silencieux. Garder les decorators stateless.
- TypeScript decorator stage — Nest est sur legacy (
experimentalDecorators: true). Si tu activesuseDefineForClassFields: true(TS 5+), les décorateurs sur fields ne voient pas la valeur initiale. Sauf pour@Prop()Mongoose ou@Column()TypeORM — risque de comportement subtil. applyDecoratorsinvoqué sans () dans le décorateur final —@Authau lieu de@Auth()est un piège. Forcer le call avec parens, et toujours typer le retour de la factory.
🧪 Testing
// Param decorator — tester via la factory exposée
// createParamDecorator retourne un decorator + une factory accessible
// Truc : extraire la fonction d'extraction dans une variable nommée et la tester directement
const extractUser = (data: keyof AuthUser | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return data ? req.user?.[data] : req.user;
};
export const CurrentUser = createParamDecorator(extractUser);
it('extrait le user complet', () => {
const ctx: any = { switchToHttp: () => ({ getRequest: () => ({ user: { id: '1', roles: ['x'] } }) }) };
expect(extractUser(undefined, ctx)).toEqual({ id: '1', roles: ['x'] });
});
it('extrait un champ', () => {
const ctx: any = { switchToHttp: () => ({ getRequest: () => ({ user: { id: '1' } }) }) };
expect(extractUser('id', ctx)).toBe('1');
});// Metadata decorator — tester via Reflector
import { Reflector } from '@nestjs/core';
class Dummy {
@Roles('admin', 'support')
method() {}
}
it('@Roles pose la metadata', () => {
const reflector = new Reflector();
const roles = reflector.get<string[]>(ROLES_KEY, Dummy.prototype.method);
expect(roles).toEqual(['admin', 'support']);
});// e2e — vérifier que @Auth() Guard + Swagger marche
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideGuard(JwtAuthGuard).useValue({ canActivate: (ctx: any) => { ctx.switchToHttp().getRequest().user = { id: '1', roles: ['admin'] }; return true; } })
.compile();🎬 Cas d'usage concrets
Scénario 1 — Cabinet d'avocats : @CurrentUser() typé
Qui — Une LegalTech FR (28 ETP) qui édite une plateforme de gestion de dossiers pour 320 cabinets d'avocats. ≈ 14 endpoints qui ont besoin du user courant.
Problème métier — Avant, chaque controller faisait req.user (untyped) ou @Req() req (couplage Express). Le typage était perdu, les refactos du token JWT cassaient silencieusement (un rename de userId en id n'était attrapé qu'à l'exécution).
Comment ce concept aide — @CurrentUser() typé fortement, retourne l'AuthUser complet ou un champ ciblé via @CurrentUser('cabinetId'). Le compilateur TS rattrape tout rename. Couplé avec un type guard pour ne pas autoriser @CurrentUser() sur une route @Public() (le user serait undefined).
export interface AuthUser {
id: string;
email: string;
cabinetId: string;
roles: ('associé' | 'collaborateur' | 'stagiaire' | 'paralegal')[];
permissions: string[];
}
export const CurrentUser = createParamDecorator(
<K extends keyof AuthUser>(data: K | undefined, ctx: ExecutionContext): AuthUser | AuthUser[K] => {
const user = ctx.switchToHttp().getRequest().user as AuthUser;
return data ? user[data] : user;
},
);
@Controller('matters')
@UseGuards(JwtAuthGuard)
export class MattersController {
@Get() list(@CurrentUser('cabinetId') cabinetId: string) {
return this.svc.listForCabinet(cabinetId);
}
@Get('me') me(@CurrentUser() user: AuthUser) { return user; }
}Gains chiffrés — 0 régression silencieuse de typage sur 14 mois (vs 2-3/an avant), code des controllers réduit en moyenne de 3 lignes par endpoint, refacto du JWT payload (passage de userId à id) déployé en 1 PR sans incident.
Scénario 2 — SaaS multi-tenant : @TenantId() avec validation
Qui — Un SaaS marketplace B2B FR (45 ETP) servant 6 500 fournisseurs. Chaque client (acheteur) a son tenant, chaque fournisseur a son organisation. Le tenant est extrait du JWT mais doit être vérifié sur chaque route business.
Problème métier — Sans decorator dédié, l'extraction du tenant variait : parfois req.user.tenantId, parfois req.headers['x-tenant'], parfois req.tenant.id. Confusion totale. Et oublier la vérif d'autorité = fuite cross-tenant.
Comment ce concept aide — @TenantId() qui (a) extrait du JWT en priorité, (b) accepte un header override si rôle admin, (c) throw si absent. Le decorator est testable isolément. Combiné avec un TenantIsolationGuard qui vérifie la cohérence.
export const TenantId = createParamDecorator((_d: unknown, ctx: ExecutionContext): string => {
const req = ctx.switchToHttp().getRequest();
const fromJwt = req.user?.tenantId;
const fromHeader = req.headers['x-tenant'] as string | undefined;
const isAdmin = req.user?.roles?.includes('admin');
if (isAdmin && fromHeader) return fromHeader; // admin peut impersonner
if (fromJwt) return fromJwt;
throw new BadRequestException('tenant_id_missing');
});
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrdersController {
@Get() list(@TenantId() tenantId: string, @CurrentUser('id') userId: string) {
return this.svc.listOrders(tenantId, userId);
}
}Gains chiffrés — 0 fuite cross-tenant en 11 mois, support de l'impersonation admin déployé en 1 jour (vs ~3 jours avec l'ancien pattern éparpillé), DX dev améliorée (un seul endroit pour comprendre la résolution tenant).
Scénario 3 — Banque : @AuditTrail() pour traçabilité réglementaire
Qui — Une banque privée FR (180 ETP IT). Exigence ACPR : chaque action sensible (modification client, validation virement, accès dossier) doit être loggée avec actor, action, target, before/after, correlation-id.
Problème métier — Mettre l'audit log dans chaque handler = duplication, oublis (notamment sur les paths d'erreur), et incompatibilité avec les microservices RabbitMQ qui ne sont pas HTTP. Les contrôles ACPR pointaient régulièrement des actions non auditées.
Comment ce concept aide — @AuditTrail('order.cancel') decorator de méthode qui pose une metadata. Un AuditInterceptor lit la metadata via Reflector, capture les inputs/outputs, et émet l'event vers Kafka. Multi-transport (HTTP + microservice RPC) via ctx.getType().
export const AUDIT_TRAIL_KEY = 'audit_trail';
export const AuditTrail = (action: string, opts?: { sensitive?: boolean }) =>
SetMetadata(AUDIT_TRAIL_KEY, { action, sensitive: opts?.sensitive ?? false });
@Controller('clients')
export class PrivateClientsController {
@Patch(':id')
@AuditTrail('client.update', { sensitive: true })
update(@Param('id') id: string, @Body() dto: UpdateDto, @CurrentUser() user: AuthUser) {
return this.svc.update(id, dto, user.id);
}
}
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector, private readonly audit: AuditProducer) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const meta = this.reflector.get<{ action: string; sensitive: boolean }>(AUDIT_TRAIL_KEY, ctx.getHandler());
if (!meta) return next.handle();
const req = ctx.switchToHttp().getRequest();
return next.handle().pipe(
tap({
next: (response) => this.audit.emit({
action: meta.action,
actor: req.user?.id,
target: req.params?.id,
input: meta.sensitive ? '[REDACTED]' : req.body,
output: meta.sensitive ? '[REDACTED]' : response,
correlationId: req.correlationId,
outcome: 'success',
}),
error: (err) => this.audit.emit({
action: meta.action,
actor: req.user?.id,
target: req.params?.id,
outcome: 'failure',
errorCode: err.code,
correlationId: req.correlationId,
}),
}),
);
}
}Gains chiffrés — Couverture d'audit passée de ~60% manuel à 100% automatique, audit ACPR validé sans réserve, 0 action sensible non auditée détectée en 13 mois (vs ~10/an avant).
🛠️ Exemple end-to-end
Use case — Banque privée. On bâtit la stack de decorators : @CurrentUser() typé multi-transport, @TenantId(), @AuditTrail(), @Auth(...roles) composé, et @ApiPaginatedResponse() pour Swagger. Le tout déployé sur un endpoint de listing clients.
// src/auth/types.ts
export interface AuthUser {
id: string;
email: string;
tenantId: string;
roles: ('admin' | 'banker' | 'compliance' | 'support')[];
permissions: string[];
clearanceLevel: 1 | 2 | 3 | 4;
}// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
import { AuthUser } from '../../auth/types';
export const CurrentUser = createParamDecorator(
<K extends keyof AuthUser>(
data: K | undefined,
ctx: ExecutionContext,
): AuthUser | AuthUser[K] => {
let user: AuthUser | undefined;
switch (ctx.getType()) {
case 'http':
user = ctx.switchToHttp().getRequest().user;
break;
case 'ws':
user = ctx.switchToWs().getClient().data?.user;
break;
case 'rpc':
user = ctx.switchToRpc().getContext().user;
break;
}
if (!user) throw new BadRequestException('user_not_authenticated');
return data ? user[data] : user;
},
);Le decorator est multi-transport — il fonctionne sur HTTP, WebSocket, et microservice RPC. Le typage générique sur K extends keyof AuthUser garantit qu'un @CurrentUser('badField') ne compile pas.
// src/common/decorators/tenant-id.decorator.ts
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
export const TenantId = createParamDecorator((_d: unknown, ctx: ExecutionContext): string => {
const req = ctx.switchToHttp().getRequest();
const user = req.user;
if (!user?.tenantId) throw new BadRequestException('tenant_required');
return user.tenantId;
});// src/common/decorators/audit-trail.decorator.ts
import { SetMetadata } from '@nestjs/common';
export interface AuditTrailOptions {
sensitive?: boolean;
resourceField?: string; // ex: 'id' → req.params.id
}
export const AUDIT_TRAIL_KEY = 'audit_trail';
export const AuditTrail = (action: string, opts: AuditTrailOptions = {}) =>
SetMetadata(AUDIT_TRAIL_KEY, { action, ...opts });// src/common/decorators/auth.decorator.ts
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiForbiddenResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
import { RolesGuard, ROLES_KEY } from '../../auth/guards/roles.guard';
export type AppRole = 'admin' | 'banker' | 'compliance' | 'support';
export function Auth(...roles: AppRole[]) {
return applyDecorators(
SetMetadata(ROLES_KEY, roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Missing or invalid token' }),
ApiForbiddenResponse({ description: 'Insufficient role' }),
);
}Le @Auth(...roles) packagé via applyDecorators factorise 4 annotations en une seule. Le typage AppRole garantit qu'aucun typo de rôle ne passe.
// src/common/decorators/api-paginated.decorator.ts
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
export class PaginatedResponse<T> {
data!: T[];
meta!: { total: number; cursor?: string; limit: number };
}
export function ApiPaginatedResponse<T extends Type<unknown>>(model: T) {
return applyDecorators(
ApiExtraModels(PaginatedResponse, model),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedResponse) },
{
properties: {
data: { type: 'array', items: { $ref: getSchemaPath(model) } },
},
},
],
},
}),
);
}// src/interceptors/audit.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { AUDIT_TRAIL_KEY, AuditTrailOptions } from '../common/decorators/audit-trail.decorator';
import { AuditProducer } from '../audit/audit.producer';
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector, private readonly audit: AuditProducer) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const meta = this.reflector.get<AuditTrailOptions & { action: string }>(
AUDIT_TRAIL_KEY,
ctx.getHandler(),
);
if (!meta) return next.handle();
const req = ctx.switchToHttp().getRequest();
const start = Date.now();
const resourceId = meta.resourceField ? req.params?.[meta.resourceField] : undefined;
return next.handle().pipe(
tap({
next: (response) => {
this.audit.emit({
action: meta.action,
actor: req.user?.id,
tenantId: req.user?.tenantId,
resourceId: resourceId ?? response?.id,
input: meta.sensitive ? '[REDACTED]' : req.body,
output: meta.sensitive ? '[REDACTED]' : null,
correlationId: req.correlationId,
durationMs: Date.now() - start,
outcome: 'success',
timestamp: new Date().toISOString(),
});
},
error: (err) => {
this.audit.emit({
action: meta.action,
actor: req.user?.id,
tenantId: req.user?.tenantId,
resourceId,
errorCode: err.code ?? err.constructor.name,
errorMessage: err.message,
correlationId: req.correlationId,
durationMs: Date.now() - start,
outcome: 'failure',
timestamp: new Date().toISOString(),
});
},
}),
);
}
}// src/private-clients/private-clients.controller.ts
import { Controller, Get, Param, Patch, Body, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Auth } from '../common/decorators/auth.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { TenantId } from '../common/decorators/tenant-id.decorator';
import { AuditTrail } from '../common/decorators/audit-trail.decorator';
import { ApiPaginatedResponse } from '../common/decorators/api-paginated.decorator';
import { PrivateClientsService } from './private-clients.service';
import { PrivateClientDto } from './dto/private-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import { AuthUser } from '../auth/types';
@ApiTags('private-clients')
@Controller({ path: 'private-clients', version: '1' })
export class PrivateClientsController {
constructor(private readonly clients: PrivateClientsService) {}
@Get()
@Auth('banker', 'admin')
@ApiPaginatedResponse(PrivateClientDto)
list(
@TenantId() tenantId: string,
@CurrentUser('id') userId: string,
@Query('cursor') cursor?: string,
@Query('limit') limit = '20',
) {
return this.clients.list({ tenantId, userId, cursor, limit: Number(limit) });
}
@Get(':id')
@Auth('banker', 'admin', 'compliance')
@AuditTrail('client.read', { resourceField: 'id' })
byId(
@Param('id') id: string,
@TenantId() tenantId: string,
@CurrentUser() user: AuthUser,
) {
return this.clients.findById(id, { tenantId, requester: user });
}
@Patch(':id')
@Auth('banker', 'admin')
@AuditTrail('client.update', { resourceField: 'id', sensitive: true })
update(
@Param('id') id: string,
@Body() dto: UpdateClientDto,
@TenantId() tenantId: string,
@CurrentUser('id') actorId: string,
) {
return this.clients.update(id, dto, { tenantId, actorId });
}
}Le controller est extrêmement déclaratif. Chaque endpoint dit exactement : qui peut accéder (@Auth), quel user est appelant (@CurrentUser), quel tenant (@TenantId), quel audit (@AuditTrail), quelle réponse Swagger (@ApiPaginatedResponse). Aucune logique répétée, aucun accès direct à req. La testabilité est maximale : on peut tester le controller en mockant simplement le service.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuditInterceptor } from './interceptors/audit.interceptor';
import { AuditModule } from './audit/audit.module';
import { PrivateClientsModule } from './private-clients/private-clients.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [AuthModule, AuditModule, PrivateClientsModule],
providers: [
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
],
})
export class AppModule {}Cet exemple démontre la puissance des custom decorators bien conçus : (a) param decorators typés multi-transport (@CurrentUser, @TenantId), (b) metadata decorators consommés par interceptor (@AuditTrail), (c) composed decorator pour packager Guards + Swagger (@Auth), (d) composed decorator typé pour OpenAPI (@ApiPaginatedResponse). Le code des handlers reste lisible et focus métier ; tout ce qui est cross-cutting est centralisé. C'est le pattern type d'une app prod-grade bien architecturée.
🔁 Quand utiliser / éviter
Utiliser un param decorator :
- Extraction répétée de données du
ExecutionContext(user, tenant, IP). - API plus déclarative dans le handler (
@CurrentUser('id') userId: string>req.user.id).
Utiliser un metadata decorator :
- Quand un Guard/Interceptor doit lire un flag/option par route (
@Roles,@Public,@CacheTTL,@Throttle).
Utiliser applyDecorators :
- Packager 3+ décorateurs récurrents (Auth + Swagger + Roles).
Éviter un decorator custom :
- Pour de la logique métier (à mettre dans service / interceptor).
- Quand un service injecté suffit (
this.req.userviaScope.REQUESTinjection — bien que pas idéal). - Si tu as besoin de DI lourde (préférer Interceptor).
Éviter applyDecorators qui devient une boîte noire — si la composition cache 5 décorateurs Swagger spécifiques par route, ça devient illisible. Séparer par usage (@AuthAdmin, @AuthUser).
🤖 Servir des agents IA : décorateurs au service d'endpoints LLM
C'est ici que les custom decorators deviennent un super-pouvoir pour un backend qui expose des agents IA. Tout ce qui est cross-cutting d'un endpoint LLM — clé d'idempotence (une génération coûte cher, jamais deux fois), garde de coût (budget par tenant), choix du modèle par route, contexte de streaming — se déclare proprement via décorateurs et se consomme dans Guards/Interceptors. Le handler reste focalisé sur l'orchestration.
Modèles Anthropic actuels (2026) : flagship
claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5. Le client doit être injecté via DI (forRootAsync), jamaisnew Anthropic()dans un champ — sinon clé d'API en dur, impossible à mocker, et pas de retries SDK partagés.
@AiModel() — choisir le modèle par route, déclarativement
// ai-model.decorator.ts — metadata decorator
import { SetMetadata } from '@nestjs/common';
export type AnthropicModel = 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5';
export const AI_MODEL_KEY = 'ai_model';
/** Pose le modèle voulu sur la route. Un Interceptor lit et configure le call. */
export const AiModel = (model: AnthropicModel) => SetMetadata(AI_MODEL_KEY, model);
// Usage : haiku pour la classification (rapide/bon marché), opus pour le raisonnement complexe
@Post('classify') @AiModel('claude-haiku-4-5') classify(@Body() dto: ClassifyDto) { /* ... */ }
@Post('reason') @AiModel('claude-opus-4-8') reason(@Body() dto: ReasonDto) { /* ... */ }@IdempotencyKey() — une génération chère ne doit jamais tourner deux fois
// idempotency-key.decorator.ts — param decorator
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/** Extrait et valide l'en-tête Idempotency-Key. Sync, O(1) — pas d'I/O ici. */
export const IdempotencyKey = createParamDecorator((_d, ctx: ExecutionContext): string => {
const key = ctx.switchToHttp().getRequest().headers['idempotency-key'] as string | undefined;
if (!key || !UUID_V4.test(key)) throw new BadRequestException('idempotency_key_required_uuid_v4');
return key;
});Le décorateur valide le format (sync) ; la déduplication réelle (lecture/écriture Redis) vit dans un Interceptor ou un service — pas dans le décorateur, car c'est de l'I/O. Mental model : le décorateur garantit qu'une clé valide existe, l'Interceptor garantit qu'on ne régénère pas.
@CostGuard() — garde de budget par tenant à l'edge
// cost-guard.decorator.ts — composed : metadata + Guard
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { LlmBudgetGuard } from '../ai/llm-budget.guard';
export const MAX_COST_USD_KEY = 'max_cost_usd';
/** Plafonne le coût estimé d'un appel agentique pour ce tenant. */
export function CostGuard(maxUsd: number) {
return applyDecorators(SetMetadata(MAX_COST_USD_KEY, maxUsd), UseGuards(LlmBudgetGuard));
}
// Usage : packagé avec l'auth dans un seul décorateur agent-ready
@Post('agent/run')
@Auth('member')
@CostGuard(0.50) // refuse si le budget tenant du jour est dépassé
@AiModel('claude-sonnet-4-6')
run(@CurrentUser('tenantId') tenantId: string, @IdempotencyKey() key: string, @Body() dto: AgentRunDto) {
return this.agent.run({ tenantId, idempotencyKey: key, ...dto });
}Le LlmBudgetGuard lit MAX_COST_USD_KEY via Reflector, interroge le compteur de dépense du tenant (Redis), et throw new HttpException('budget_exceeded', 402) avant même de toucher l'LLM. Échouer à l'edge coûte 0 token.
@StreamToken() — décorateur composé pour un endpoint SSE de streaming
// stream-token.decorator.ts — composé : pose les headers SSE + marque la route comme streaming
import { applyDecorators, Header, SetMetadata } from '@nestjs/common';
export const IS_SSE_STREAM_KEY = 'is_sse_stream';
export function StreamToken() {
return applyDecorators(
SetMetadata(IS_SSE_STREAM_KEY, true),
Header('Content-Type', 'text/event-stream'),
Header('Cache-Control', 'no-cache, no-transform'),
Header('Connection', 'keep-alive'),
Header('X-Accel-Buffering', 'no'), // désactive le buffering NGINX — sinon les tokens arrivent par paquets
);
}Le handler renvoie un Observable<MessageEvent> (Nest gère le SSE nativement avec @Sse()), ou écrit dans le flux manuellement. La boucle agentique côté serveur (tool-use) émet tool_use / tool_result / text comme événements SSE typés. Le AbortController est câblé sur la déconnexion client pour ne pas continuer à brûler des tokens quand l'onglet est fermé :
@Sse('agent/stream')
@StreamToken()
stream(@CurrentUser('tenantId') tenantId: string, @Req() req: Request): Observable<MessageEvent> {
const ac = new AbortController();
req.on('close', () => ac.abort()); // client parti → on annule le stream Anthropic
return this.agent.streamRun({ tenantId, signal: ac.signal }); // signal passé au SDK Anthropic
}Job IA en BullMQ : le décorateur fournit la clé d'idempotence
Pour les générations longues (rapport multi-pages), on enqueue plutôt qu'on streame en synchrone. La clé d'idempotence extraite par @IdempotencyKey() devient le jobId BullMQ — BullMQ dédoublonne nativement sur jobId, donc un POST rejoué ne crée pas un second job (donc pas un second coût). Retry cost-aware : on ne retente que sur erreurs transitoires (429, 529 overloaded, réseau), jamais sur une réponse partielle déjà facturée — on reprend alors depuis le dernier checkpoint sauvegardé, pas depuis zéro.
@Post('reports/generate')
@Auth('member') @CostGuard(2.00)
async enqueue(@IdempotencyKey() key: string, @CurrentUser('tenantId') tenantId: string, @Body() dto: ReportDto) {
await this.queue.add('generate-report', { tenantId, ...dto }, {
jobId: key, // dédoublonnage natif sur la clé d'idempotence
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
return { status: 'accepted', generationId: key };
}Synthèse stack-IA : les décorateurs custom transforment un endpoint LLM en contrat déclaratif — modèle (@AiModel), budget (@CostGuard), idempotence (@IdempotencyKey), streaming (@StreamToken) — pendant que la logique d'orchestration (boucle tool-use, retries SDK, AbortController) reste dans des services injectés et testables. Côté Angular, l'UI consomme ce flux SSE avec un getReader()/TextDecoder et des signals append-only (voir le fichier Angular correspondant).
🧰 Exemples avancés
@ApiPaginatedResponse() — Swagger composé
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
class Paginated<T> {
data!: T[];
meta!: { total: number; cursor?: string };
}
export function ApiPaginatedResponse<T extends Type<unknown>>(model: T) {
return applyDecorators(
ApiExtraModels(Paginated, model),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(Paginated) },
{
properties: {
data: { type: 'array', items: { $ref: getSchemaPath(model) } },
},
},
],
},
}),
);
}
// Usage
@Get() @ApiPaginatedResponse(UserDto)
list() { return this.users.list(); }@CurrentUser() multi-transport (HTTP + WS)
export const CurrentUser = createParamDecorator(
(data: keyof AuthUser | undefined, ctx: ExecutionContext) => {
let user: AuthUser | undefined;
switch (ctx.getType()) {
case 'http': user = ctx.switchToHttp().getRequest().user; break;
case 'ws': user = ctx.switchToWs().getClient().data?.user; break;
case 'rpc': user = ctx.switchToRpc().getContext().user; break;
}
return data ? user?.[data] : user;
},
);@Cookies() typé
export const Cookies = createParamDecorator((name: string | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return name ? req.cookies?.[name] : req.cookies;
});
// Usage
@Get('me')
me(@Cookies('session') session?: string) {
if (!session) throw new UnauthorizedException();
return this.auth.validate(session);
}🏋️ Exercices
Progression : implémenter → rendre prod-grade → casser puis réparer. Chaque exercice se code dans un projet Nest 11 + TS 5.x.
1. @CurrentUser() typé et générique — implémenter
Objectif — Écrire un param decorator générique tel que @CurrentUser('id') rende string, @CurrentUser('roles') rende Role[], et @CurrentUser() rende AuthUser, le tout au niveau du type (pas de cast).
Indice/Solution — Signature <K extends keyof AuthUser>(data: K | undefined, ctx) => AuthUser | AuthUser[K]. Extraire la closure dans une fonction nommée pour la tester sans HTTP. Vérifier qu'un @CurrentUser('inexistant') ne compile pas.
2. @TenantId() + TenantIsolationGuard — prod-grade
Objectif — Le décorateur extrait le tenant (JWT prioritaire, header x-tenant autorisé seulement si rôle admin). Un Guard vérifie ensuite qu'aucune ressource d'un autre tenant ne fuit (cohérence req.params.tenantId === user.tenantId).
Indice/Solution — Throw 403 si incohérence. Tester le cas impersonation admin et le cas attaque (un member qui forge x-tenant). Le décorateur reste sync ; toute la décision d'autorité est dans le Guard (qui, lui, a la DI).
3. @AiModel() + @CostGuard() + Interceptor de coût — prod-grade, stack IA
Objectif — @AiModel('claude-haiku-4-5') pose le modèle, @CostGuard(0.10) plafonne le budget. Un Interceptor lit le modèle via Reflector, estime le coût d'après les tokens d'entrée, et incrémente le compteur Redis du tenant après la réponse (coût réel via usage du SDK Anthropic).
Indice/Solution — getAllAndOverride([getHandler(), getClass()]) pour permettre un modèle par défaut au niveau classe, surchargé par méthode. Le Guard refuse à l'edge (402) si le budget journalier est dépassé avant l'appel. L'Interceptor ajuste après coup avec le coût réel.
4. @StreamToken() SSE avec AbortController — prod-grade, stack IA
Objectif — Endpoint @Sse() qui streame des tokens d'un agent. Quand le client ferme l'onglet, le stream Anthropic est annulé (plus aucun token facturé). Émettre des événements SSE typés text / tool_use / done.
Indice/Solution — req.on('close', () => ac.abort()), passer ac.signal au SDK. Tester en coupant la connexion à mi-stream et en vérifiant (log/spy) que le SDK reçoit bien l'abort. Piège : sans X-Accel-Buffering: no, NGINX bufferise et les tokens arrivent en paquets.
5. Casser puis réparer — fuite d'état module-level
Objectif — On te donne un @RateLimited() qui déclare const counts = new Map() au niveau module dans le fichier du décorateur. Reproduire le bug (compteur partagé entre tous les tenants et entre tous les tests, jamais reset), puis le réparer.
Indice/Solution — Le Map module-level est un singleton partagé → cross-tenant leak + tests qui se polluent. Fix : déplacer l'état dans un provider injecté (Scope.DEFAULT avec store Redis, ou clé par tenant). Le décorateur ne doit jamais tenir d'état mutable. Démontrer par un test qui passe seul mais échoue en suite.
6. Casser puis réparer — @CurrentUser() sur route @Public()
Objectif — Reproduire le crash : un @CurrentUser() qui throw quand req.user est undefined, posé sur une route @Public() (pas de Guard d'auth) → toutes les requêtes publiques renvoient 400. Réparer sans casser les routes protégées.
Indice/Solution — Le décorateur ne doit pas décider de la politique d'auth (c'est le rôle du Guard). Option A : retourner null et laisser le handler décider. Option B : une variante @CurrentUserOptional(). Tester les deux chemins (public → null, protégé → user présent).
7. Casser puis réparer — data figé au boot vs valeur par requête
Objectif — On te donne un @Header2() censé lire un header dont le nom est calculé par requête : @Header2(() => req.someDynamicName). Reproduire le bug (le nom est figé au chargement de la classe, jamais réévalué), puis corriger l'architecture.
Indice/Solution — L'argument data d'un param decorator est capturé une seule fois au boot — il ne peut pas dépendre de la requête. Le seul input vivant par requête est ctx. Fix : passer un nom statique en data et faire toute la logique conditionnelle dans la closure à partir de ctx, ou déplacer la résolution dynamique dans un Interceptor qui pose le résultat sur req. Démontrer en logguant que la lambda de résolution n'est appelée qu'une fois.
8. Stack IA — @AiModel() override classe/méthode + fallback cost-aware
Objectif — Un controller pose @AiModel('claude-sonnet-4-6') au niveau classe (défaut), surchargé par @AiModel('claude-opus-4-8') sur la méthode reason. Un Interceptor résout le modèle via getAllAndOverride([getHandler(), getClass()]), appelle le SDK Anthropic en streaming, et rétrograde sur claude-haiku-4-5 si l'appel échoue avec 529 overloaded après 2 retries — en respectant le CostGuard.
Indice/Solution — getAllAndOverride (pas get) sinon le défaut classe est ignoré. Les retries transitoires (429/529) sont gérés par le SDK (maxRetries) ; le fallback modèle est une décision applicative au-dessus, dans l'Interceptor, déclenchée seulement quand les retries SDK sont épuisés. Piège : recompter le budget sur le modèle de fallback (haiku coûte moins — ne pas re-débiter le tarif opus). Tester en mockant un 529 persistant et en vérifiant le modèle effectivement appelé + le coût débité.
🎤 En entretien
Q — Pourquoi ne peut-on pas injecter un service dans un createParamDecorator ? Parce que la closure d'extraction est instanciée par le runtime de Nest hors du conteneur DI — elle n'a pas de référence à l'injecteur. Le pattern : un Interceptor (lui, dans la DI) enrichit req, et le décorateur se contente de lire ce qui a été déposé. Écrit/lit, pas fait/injecté.
Q — Quand getAllAndOverride plutôt que getAllAndMerge ?Override quand le plus spécifique l'emporte (rôles, @Public, TTL de cache) — la méthode surcharge la classe. Merge quand on accumule (permissions cumulatives, feature flags). Choisir get(getHandler()) seul est un bug de sécurité classique : un @Public() posé au niveau classe est alors ignoré.
Q — Un @RealIp() qui lit x-forwarded-for est-il sûr pour du rate-limiting ? Non si on lit le header brut : tout client peut le forger. Il ne faut s'y fier que derrière un proxy de confiance configuré via trust proxy (Express nettoie alors req.ip). Sinon c'est un vecteur de contournement de rate-limit et de poisoning d'allow-list.
Q — À quel moment s'exécute le corps d'une factory de décorateur composé comme @Auth() ? Une seule fois, au chargement de la classe (au boot), pas par requête. C'est pour ça qu'on peut y valider la config et throw tôt. À distinguer de la closure d'un param decorator, qui, elle, tourne par requête et doit rester synchrone et O(1).
Q — Dans @CurrentUser('id', ParseUUIDPipe), qui tourne en premier, la closure ou le pipe ? La closure d'abord (elle extrait user.id), puis le pipe valide/transforme la valeur extraite. Nest chaîne factory(data, ctx) → pipe.transform(value). C'est ce chaînage qui permet de composer un param decorator custom avec les pipes natifs sans réimplémenter la validation.
Q — @CurrentUser() lit req.user. Qui le pose, et que se passe-t-il si l'auth est un Interceptor post-handler ? C'est un Guard (ou un middleware Passport) qui pose req.user, avant le handler. L'ordre du pipeline est Guards → Interceptors pré → Pipes → param decorators → Handler. Si l'auth vit dans un Interceptor post-handler, req.user est undefined au moment où le décorateur lit — bug de timing silencieux qui se traduit par des 400/null intermittents.
🔗 Liens
- Docs Nest — Custom Decorators
- Docs Nest — Execution Context
- reflect-metadata
- @nestjs/swagger —
ApiExtraModels - Voir
02-guards.md(Reflector + metadata) et04-pipes-validation.md(combiner pipe + param decorator).