Decorators TypeScript Stage 3 vs legacy : class, method, accessor, factory, reflect-metadata, patterns log/memoize/retry/validate et integration NestJS.
Qu'est-ce qu'un decorator et pourquoi en écrire ?
Un decorator est une fonction qui s'applique à une classe, une méthode, une propriété, un accesseur ou un paramètre via la syntaxe spéciale @nom. Au moment où TypeScript ou le runtime exécute la définition de la classe, chaque décorateur est invoqué et reçoit la cible à annoter ou à modifier. Il peut alors : ajouter des métadonnées, wrap une méthode pour intercepter ses appels, modifier le constructeur, remplacer une propriété par un accesseur, ou simplement enregistrer la classe quelque part.
C'est de la métaprogrammation — du code qui agit sur d'autres morceaux de code. Si vous écrivez du Angular, du NestJS ou du TypeORM, vous en utilisez déjà sans le savoir : @Component, @Injectable, @Controller, @Entity sont tous des décorateurs. L'objectif de cet article : comprendre comment ils fonctionnent sous le capot et écrire les vôtres pour résoudre des problèmes transversaux (logging, cache, validation, retry) sans polluer le code métier.
Ce que cet article couvre
- La différence cruciale entre decorators legacy (TypeScript historique) et Stage 3 (norme ECMAScript moderne).
- La configuration TypeScript pour activer chaque mode.
- Les 5 types de décorateurs : Class, Method, Accessor, Property, Parameter.
- Les decorator factories pour passer des paramètres.
- Six patterns prêts à l'emploi :
@log,@memoize,@debounce,@retry,@validate,@deprecated. - Le rôle de
reflect-metadataet la magie de l'injection de dépendances Angular/NestJS. - Comment tester un décorateur avec Vitest, et les pièges classiques à éviter.
Quand et pourquoi écrire ses propres décorateurs
Vous écrivez le même bloc de code (try/catch avec logging, mesure du temps d'exécution, vérification d'autorisation, gestion de cache) dans dix méthodes différentes ? C'est probablement candidat à un décorateur. Une fois extrait, l'intention est exposée au-dessus de la méthode (@audit, @cached, @requireRole('admin')) plutôt que noyée dans son corps. La lecture du code en bénéficie directement : on voit ce que la méthode fait sans avoir à scanner ses tripes.
À l'inverse, n'écrivez pas de décorateur pour ce qui n'a besoin que d'être appelé une fois — préférez une fonction utilitaire classique. Et n'écrivez jamais un décorateur dont le comportement dépend de variables globales mutables : c'est la garantie d'avoir des bugs de configuration qui n'apparaissent qu'en production.
Decorators legacy vs Stage 3 — le grand changement
En 2023, après dix ans de status « proposal stage 2 », les décorateurs sont passés en Stage 3 du processus TC39 et ont été stabilisés. TypeScript 5.0 (mars 2023) a livré la nouvelle implémentation, distincte de l'ancienne. Les deux coexistent dans le compilateur via deux options du tsconfig.json qui s'excluent mutuellement.
Si vous avez écrit du TypeScript entre 2015 et 2022, vous connaissez probablement uniquement la version legacy. La transition vers Stage 3 n'est pas obligatoire pour les projets existants — les deux modes peuvent coexister sur la même base de code, à condition de cantonner chaque décorateur à son mode. Migrer une lib entière demande du temps mais est rarement urgent.
Tableau comparatif
| Critère | Legacy (experimentalDecorators) | Stage 3 (TypeScript 5+) |
|---|---|---|
| Activation | experimentalDecorators: true | Par défaut depuis TS 5.0 |
| Signature classe | (constructor) | (value, context) |
| Signature méthode | (target, key, descriptor) | (original, context) |
| Parameter decorator | ✓ supporté | ✗ non supporté (à venir) |
| reflect-metadata | ✓ combiné à emitDecoratorMetadata | ✗ non intégré |
| Utilisé par | Angular, NestJS, TypeORM, class-validator | Nouveaux projets, libs modernes |
Quel mode choisir ?
- Projet Angular ou NestJS — restez en legacy. Ces frameworks dépendent de
reflect-metadataetemitDecoratorMetadatapour leur DI automatique. - Nouveau projet sans dépendance lourde — utilisez Stage 3. C'est la norme ECMAScript moderne, supportée par tous les bundlers (Vite, esbuild, Rollup, SWC).
- Lib partagée — proposez les deux variantes si vous publiez sur npm, ou exigez Stage 3 et documentez clairement la contrainte.
Configurer TypeScript pour les decorators
Mode Stage 3 (recommandé en 2026)
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"experimentalDecorators": false, // important : explicitement false
"emitDecoratorMetadata": false
}
}
Mode legacy (Angular, NestJS, TypeORM)
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
// Et dans le code, importez reflect-metadata une seule fois au démarrage :
// main.ts ou polyfills.ts
import 'reflect-metadata';
Compatibilité bundlers et build
Vite, esbuild et SWC supportent les décorateurs Stage 3 nativement depuis 2023. Si vous êtes en mode legacy, vérifiez que votre bundler est configuré pour transpiler avec les bonnes options : Vite via vite-plugin-typescript-decorators ou en passant par le compilateur TypeScript officiel pour les fichiers concernés. Webpack avec ts-loader supporte les deux modes selon le tsconfig.json. Pour Babel, vous aurez besoin de @babel/plugin-proposal-decorators avec la bonne version ({ version: '2023-11' } pour Stage 3).
emitDecoratorMetadata indique au compilateur de conserver les types TypeScript en runtime (encodés sous forme de métadonnées via Reflect.metadata). C'est ce qui permet à Angular de savoir, sans annotation explicite, qu'constructor(http: HttpClient) doit recevoir une instance de HttpClient. Sans cette option, les types disparaissent à la compilation et l'injection automatique cesse de fonctionner.
Class Decorator — modifier une classe entière
Le décorateur de classe reçoit le constructeur (ou la classe en Stage 3) et peut le remplacer, l'étendre, ou simplement enregistrer des métadonnées. C'est ce qu'utilise @Component d'Angular pour attacher la configuration au composant.
Version Stage 3 — décorateur de classe simple
// Décorateur qui logge chaque instanciation
function Loggable<T extends new (...args: any[]) => object>(
value: T,
context: ClassDecoratorContext,
): T {
return class extends value {
constructor(...args: any[]) {
super(...args);
console.log(`[${String(context.name)}] instance créée`);
}
} as T;
}
@Loggable
class Counter {
count = 0;
increment() { this.count++; }
}
new Counter(); // → [Counter] instance créée
Version legacy — équivalent fonctionnel
// Avec experimentalDecorators: true
function Loggable<T extends { new(...args: any[]): {} }>(constructor: T) {
return class extends constructor {
constructor(...args: any[]) {
super(...args);
console.log(`[${constructor.name}] instance créée`);
}
};
}
@Loggable
class Counter { /* ... */ }
Notez la différence de signature : Stage 3 reçoit un context riche qui contient name, kind, addInitializer et plus. C'est plus type-safe et plus extensible que les trois arguments du mode legacy.
Method Decorator — intercepter un appel
Le décorateur de méthode est le plus utilisé en pratique. Il permet d'envelopper la méthode originale pour ajouter du comportement avant, après, ou autour de son exécution — sans toucher au code métier.
Version Stage 3 — @log qui trace les appels
function log<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
const methodName = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`→ ${methodName}(${JSON.stringify(args)})`);
const result = originalMethod.apply(this, args);
console.log(`← ${methodName} =`, result);
return result;
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
new Calculator().add(2, 3);
// → add([2,3])
// ← add = 5
Version legacy
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`→ ${propertyKey}(${JSON.stringify(args)})`);
const result = original.apply(this, args);
console.log(`← ${propertyKey} =`, result);
return result;
};
return descriptor;
}
This, Args, Return. Le wrapper retourné conserve la même signature que la méthode d'origine — l'IDE vous montre les types exacts à l'appel.
Accessor et Property Decorator
Accessor Decorator (getter/setter)
// Décore un get/set — peut intercepter lecture et écriture
function logged<T>(
{ get, set }: { get: (this: any) => T; set: (this: any, v: T) => void },
context: ClassAccessorDecoratorContext,
) {
return {
get() {
const value = get.call(this);
console.log(`get ${String(context.name)} =`, value);
return value;
},
set(value: T) {
console.log(`set ${String(context.name)} =`, value);
set.call(this, value);
},
};
}
class User {
@logged
accessor name: string = 'Alice';
}
const u = new User();
u.name; // get name = Alice
u.name = 'Bob'; // set name = Bob
Notez le mot-clé accessor dans accessor name: string — c'est une nouveauté Stage 3 qui transforme un champ en getter/setter automatiquement, ce qui rend le décorateur accessor applicable.
Property Decorator (legacy uniquement)
// Property decorators n'existent QUE en mode legacy
function Required(target: any, propertyKey: string) {
let value: any;
Object.defineProperty(target, propertyKey, {
get() { return value; },
set(v: any) {
if (v == null) throw new Error(`${propertyKey} is required`);
value = v;
},
});
}
class User {
@Required
name!: string;
}
Stage 3 a délibérément retiré les property decorators car ils n'avaient pas accès à la valeur initiale. À la place, on utilise accessor + accessor decorator, ce qui est plus expressif et plus type-safe.
Decorator Factory — décorateurs paramétrables
Pour passer des paramètres à un décorateur (@retry(3), @cache({ ttl: 60 })), on utilise une factory : une fonction qui retourne le décorateur. C'est le pattern le plus courant en pratique.
// @retry(3, 1000) → relance jusqu'à 3 fois avec 1s d'attente
function retry(maxAttempts = 3, delayMs = 1000) {
return function<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Promise<Return>,
context: ClassMethodDecoratorContext,
) {
return async function (this: This, ...args: Args): Promise<Return> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await originalMethod.apply(this, args);
} catch (err) {
lastError = err;
console.warn(`[${String(context.name)}] tentative ${attempt} échouée`);
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, delayMs * attempt));
}
}
}
throw lastError;
};
};
}
class Api {
@retry(3, 500)
async fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('HTTP error');
return res.json();
}
}
La factory rend les décorateurs réutilisables et configurables. @retry(5, 2000) sur une méthode lente du backend, @retry() avec les valeurs par défaut sur une méthode HTTP simple — le même décorateur, deux configurations.
Patterns réels : log, memoize, debounce, retry, validate
@memoize — cacher le résultat selon les arguments
function memoize<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext,
) {
const cache = new Map<string, Return>();
return function (this: This, ...args: Args): Return {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = originalMethod.apply(this, args);
cache.set(key, result);
return result;
};
}
class Geometry {
@memoize
fibonacci(n: number): number {
return n < 2 ? n : this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
@debounce — éviter les appels trop fréquents
function debounce(ms = 300) {
return function<This, Args extends any[]>(
originalMethod: (this: This, ...args: Args) => void,
context: ClassMethodDecoratorContext,
) {
let timerId: ReturnType<typeof setTimeout> | null = null;
return function (this: This, ...args: Args): void {
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => originalMethod.apply(this, args), ms);
};
};
}
class Search {
@debounce(400)
performSearch(query: string) {
console.log('Recherche:', query);
}
}
Le décorateur @memoize ci-dessus a un piège : le cache est partagé entre toutes les instances de la classe (variable capturée dans la closure). C'est parfait pour des fonctions pures (Fibonacci, factorisations), désastreux pour des méthodes qui dépendent de this. Pour un cache par instance, utilisez une WeakMap<This, Map> indexée sur l'instance — un peu plus complexe mais correct dans tous les cas.
@deprecated — avertir lors de l'usage d'une méthode obsolète
function deprecated(replacement?: string) {
return function<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext,
) {
let warned = false;
return function (this: This, ...args: Args): Return {
if (!warned) {
warned = true;
console.warn(
`[DEPRECATED] ${String(context.name)} is deprecated.`,
replacement ? `Use ${replacement} instead.` : '',
);
}
return originalMethod.apply(this, args);
};
};
}
class Api {
@deprecated('fetchUserById')
getUser(id: string) { /* ... */ }
}
@validate — vérifier les arguments avant exécution
type Validator = (value: unknown) => boolean;
function validate(...validators: Validator[]) {
return function<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext,
) {
return function (this: This, ...args: Args): Return {
args.forEach((arg, i) => {
if (validators[i] && !validators[i](arg)) {
throw new Error(`${String(context.name)} arg #${i} invalide: ${arg}`);
}
});
return originalMethod.apply(this, args);
};
};
}
// Validators réutilisables
const isString = (v: unknown) => typeof v === 'string' && v.length > 0;
const isPosInt = (v: unknown) => Number.isInteger(v) && (v as number) > 0;
class Users {
@validate(isString, isPosInt)
paginate(query: string, page: number) { /* ... */ }
}
reflect-metadata et la magie d'Angular/NestJS
reflect-metadata (polyfill de l'API Reflect.metadata) est ce qui permet aux décorateurs de stocker et lire des informations attachées à une classe ou un membre. Sans elle, un décorateur ne peut qu'envelopper du comportement — il ne peut pas mémoriser que cette classe est une @Entity ou que cette méthode est exposée sur GET /users.
import 'reflect-metadata';
// Décorateur qui marque une classe comme controller HTTP
function Controller(basePath: string) {
return function (target: any) {
Reflect.defineMetadata('basePath', basePath, target);
};
}
function Get(path: string) {
return function (target: any, propertyKey: string) {
Reflect.defineMetadata('route', { method: 'GET', path }, target, propertyKey);
};
}
@Controller('/users')
class UserController {
@Get('/:id')
getById(id: string) { /* ... */ }
}
// Ailleurs — un framework lit les métadonnées pour câbler les routes
const basePath = Reflect.getMetadata('basePath', UserController);
const route = Reflect.getMetadata('route', UserController.prototype, 'getById');
console.log(basePath, route);
// → /users { method: 'GET', path: '/:id' }
C'est exactement le mécanisme qu'utilisent NestJS pour câbler ses routes, TypeORM pour mapper ses entités, et class-validator pour exécuter ses règles. emitDecoratorMetadata: true ajoute en plus les types TypeScript aux métadonnées (param types, return type) — ce qui permet à NestJS de faire @Get() getAll(): User[] et de récupérer le type User au runtime pour la sérialisation.
Limite du reflect-metadata avec Stage 3
Les décorateurs Stage 3 n'intègrent pas le pattern reflect-metadata par défaut. Cela signifie : pour faire la DI automatique d'Angular ou NestJS avec Stage 3, il faudrait écrire votre propre système de stockage de métadonnées via context.addInitializer. C'est faisable mais coûteux, et c'est l'une des raisons principales pour lesquelles Angular et NestJS restent en mode legacy en 2026 — la migration entière demanderait de réécrire leur cœur de DI.
Decorators dans l'écosystème Angular et NestJS
Angular — qui décore quoi ?
@Component,@Directive,@Pipe— class decorators qui attachent la configuration au TypeScript class.@Injectable— déclare qu'une classe peut être injectée, et précise sa portée (providedIn: 'root').@Input,@Output— property decorators historiques (en migration versinput()/output()en Angular 17+).@ViewChild,@ContentChild,@HostListener,@HostBinding— propertyMethod decorators (également en migration versviewChild()et metadatahost).
NestJS — un univers de décorateurs
@Controller(path),@Get,@Post,@Put,@Delete— routing HTTP.@Body,@Param,@Query,@Headers— extraction des données de la requête.@Injectable,@Inject— DI similaire à Angular.@UseGuards,@UseInterceptors,@UsePipes— middlewares déclaratifs.@Module,@Global— organisation modulaire.
Toute la magie de NestJS et d'Angular repose sur deux briques : les décorateurs (qui annotent) et reflect-metadata (qui stocke). Comprendre ces deux mécanismes vous permet de lire le code source de ces frameworks et même d'écrire vos propres extensions.
class-validator — l'exemple le plus pédagogique
// Avec class-validator
import { IsEmail, IsInt, Min, Max, validate } from 'class-validator';
class CreateUserDto {
@IsEmail()
email!: string;
@IsInt()
@Min(0)
@Max(120)
age!: number;
}
const dto = new CreateUserDto();
dto.email = 'bad-email';
dto.age = 200;
const errors = await validate(dto);
// errors[0] = { property: 'email', constraints: { isEmail: 'email must be an email' } }
// errors[1] = { property: 'age', constraints: { max: 'age must not be greater than 120' } }
Chaque décorateur @IsEmail, @Min, @Max attache une règle de validation aux métadonnées de la classe via reflect-metadata. La fonction validate() lit ces métadonnées au runtime et exécute chaque règle sur la valeur correspondante. Le résultat : zéro code de validation manuel, tout est déclaratif au niveau du DTO. C'est l'approche standard en backend NestJS et l'exemple à étudier pour comprendre le pattern décorateur + metadata.
Tester un decorator avec Vitest
// retry.decorator.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { retry } from './retry.decorator';
describe('@retry', () => {
it('retente jusqu'au succès', async () => {
let attempts = 0;
class Api {
@retry(3, 0)
async fetch() {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'ok';
}
}
const result = await new Api().fetch();
expect(result).toBe('ok');
expect(attempts).toBe(3);
});
it('throw après maxAttempts dépassé', async () => {
class Api {
@retry(2, 0)
async fetch() { throw new Error('always fail'); }
}
await expect(new Api().fetch()).rejects.toThrow('always fail');
});
});
Les décorateurs sont testables comme n'importe quelle classe — vous appelez la méthode décorée et vérifiez son comportement. La spec ne devrait pas dépendre des détails internes du décorateur, juste de son contrat observable.
Pour les décorateurs asynchrones, n'oubliez pas de retourner les promesses et d'attendre leur résolution avec await dans le test. Une erreur silencieuse qui se déclenche après la fin du test est invisible avec Vitest par défaut — utilisez --reporter verbose pour les voir clignoter en sortie standard.
Pattern recommandé : décorateurs purs + tests d'intégration
Le piège classique en testant un décorateur, c'est de mocker tout l'environnement (timers, console, fetch) et de finir avec un test qui ne valide plus rien d'utile. Préférez l'approche inverse : utilisez les vraies dépendances autant que possible. vi.useFakeTimers() pour @debounce, un objet stub avec une méthode throw pour @retry, une Map réelle pour @memoize. Le code de test reste lisible et représente fidèlement le comportement en production.
Pièges classiques et bonnes pratiques
- Écrire des décorateurs idempotents — décorer 2 fois ne doit pas avoir d'effet de bord.
- Préserver la signature TypeScript du wrapper (
This, Args, Returnen Stage 3). - Documenter le comportement d'un décorateur custom comme on documente une fonction publique.
- Choisir Stage 3 pour les nouveaux projets sans dépendance Angular/NestJS.
- Tester chaque décorateur en isolation avec Vitest ou Jest.
- Mélanger Stage 3 et legacy dans le même projet — comportement indéfini.
- Capturer un
thisnon lié dans un décorateur de méthode (utilisez toujoursthis: This). - Faire des opérations asynchrones dans un class decorator — il est synchrone par contrat.
- Stocker l'état partagé dans une variable au scope du module (problèmes en SSR et en tests).
- Multiplier les décorateurs sur une seule méthode — au-delà de 3, refactorisez la logique en utilities.
Mini-projet appliqué — UserController NestJS avec décorateurs custom
Pour matérialiser ce qu'on vient de voir, construisons un cas d'usage réel : un UserController NestJS où chaque endpoint utilise 3 décorateurs custom en plus des décorateurs natifs — @CacheResponse (cache mémoire 60s), @RateLimit (10 req/min/IP), @AuditLog (trace audit RGPD). Le code applicatif reste lisible, la logique transversale est mutualisée.
1. Décorateur @CacheResponse — cache mémoire avec TTL
Le pattern memoize avec invalidation par TTL. Utile pour les endpoints de lecture coûteux (agrégations, queries SQL lentes). Pour comprendre les types de retour génériques, voir le guide des génériques TypeScript.
// Decorator legacy (mode NestJS) — Stage 3 disponible avec adaptation
function CacheResponse(ttlSeconds: number) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
const cache = new Map<string, { value: unknown; expiresAt: number }>();
descriptor.value = async function (...args: unknown[]) {
const key = `${propertyKey}:${JSON.stringify(args)}`;
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const result = await original.apply(this, args);
cache.set(key, { value: result, expiresAt: Date.now() + ttlSeconds * 1000 });
return result;
};
return descriptor;
};
}
2. Décorateur @RateLimit — anti-abus par IP
Pattern token bucket simplifié. En production, on utilise Redis pour partager le compteur entre instances ; ici, Map en mémoire pour la démo.
const ipBuckets = new Map<string, { count: number; resetAt: number }>();
function RateLimit(maxPerMinute: number) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = async function (req: { ip: string }, ...rest: unknown[]) {
const now = Date.now();
const bucket = ipBuckets.get(req.ip) ?? { count: 0, resetAt: now + 60_000 };
if (now > bucket.resetAt) { bucket.count = 0; bucket.resetAt = now + 60_000; }
bucket.count++;
ipBuckets.set(req.ip, bucket);
if (bucket.count > maxPerMinute) {
throw new HttpException(`Rate limit exceeded: ${maxPerMinute}/min`, 429);
}
return original.apply(this, [req, ...rest]);
};
return descriptor;
};
}
3. Décorateur @AuditLog — traçabilité RGPD
Trace chaque appel avec l'utilisateur authentifié, l'action, et un hash de l'input. Obligatoire pour les endpoints qui touchent à des données personnelles (RGPD art. 30).
interface AuditEntry {
timestamp: Date;
userId: string | null;
action: string;
payloadHash: string;
durationMs: number;
success: boolean;
}
function AuditLog(action: string) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = async function (req: { user?: { id: string } }, ...args: unknown[]) {
const start = Date.now();
const payloadHash = sha256(JSON.stringify(args)).slice(0, 16);
let success = false;
try {
const result = await original.apply(this, [req, ...args]);
success = true;
return result;
} finally {
const entry: AuditEntry = {
timestamp: new Date(),
userId: req.user?.id ?? null,
action,
payloadHash,
durationMs: Date.now() - start,
success,
};
// En prod : envoyer dans Kafka/Elasticsearch/PostgreSQL audit table
await auditService.write(entry);
}
};
return descriptor;
};
}
4. UserController final — code applicatif lisible
Avec les 3 décorateurs assemblés, le code du contrôleur reste centré sur sa responsabilité : router HTTP + appel service. Pour les interface de contrat ci-dessous, voir notre guide sur quand utiliser type ou interface.
@Controller('users')
export class UserController {
constructor(private readonly users: UserService) {}
@Get(':id')
@CacheResponse(60) // cache 60s
@RateLimit(30) // 30 req/min/IP
@AuditLog('user.read') // trace RGPD
async findOne(@Req() req: Request, @Param('id') id: string): Promise<User> {
const user = await this.users.findById(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
@Post()
@RateLimit(5) // 5 créations/min/IP — anti-spam
@AuditLog('user.create')
async create(@Req() req: Request, @Body() dto: CreateUserDto): Promise<User> {
return this.users.create(dto);
}
@Patch(':id')
@RateLimit(10)
@AuditLog('user.update')
async update(@Req() req: Request, @Param('id') id: string, @Body() dto: UpdateUserDto): Promise<User> {
return this.users.update(id, dto);
}
}
5. Composition + ordre d'application
Les décorateurs s'appliquent de bas en haut. L'ordre d'exécution au runtime est l'inverse : le décorateur le plus haut est exécuté en premier (c'est lui qui "enveloppe" tout le reste). Dans notre exemple :
// Au moment de la définition (compile-time) :
// 1. AuditLog applique son wrapper sur findOne
// 2. RateLimit applique son wrapper sur le résultat (1)
// 3. CacheResponse applique son wrapper sur le résultat (2)
// Au runtime (appel HTTP) :
// 1. CacheResponse vérifie le cache → court-circuit possible
// 2. RateLimit vérifie le quota → 429 possible
// 3. AuditLog démarre le timer + capture du payload
// 4. findOne s'exécute
// 5. AuditLog log le résultat
// 6. RateLimit incrémente le compteur
// 7. CacheResponse stocke le résultat
Bonne pratique : placer les décorateurs avec court-circuit en haut (cache, rate-limit) pour éviter d'exécuter ceux du dessous quand ils ne sont pas nécessaires. Pour aller plus loin sur la composition de comportements, lire les utility types pour typer les paramètres dynamiques et les type guards pour valider les arguments à runtime.
Conclusion
Les décorateurs sont l'arme secrète de TypeScript pour la métaprogrammation propre : ils permettent d'ajouter des comportements transversaux (log, cache, retry, validation, rate-limiting) sans toucher au code métier. Bien employés, ils éliminent la duplication et structurent l'application autour de concepts déclaratifs ; mal employés, ils introduisent une couche de magie difficile à débuguer. La distinction est culturelle : un bon décorateur est documenté, testé, et n'a aucun effet de bord caché.
En 2026, la voie à privilégier dépend de votre contexte. Pour les nouveaux projets sans Angular/NestJS, partez sur les décorateurs Stage 3 — c'est la norme ECMAScript officielle, supportée nativement par TypeScript 5+ et tous les bundlers modernes. Pour les projets Angular ou NestJS, restez en legacy avec experimentalDecorators et reflect-metadata — c'est ce qui fait tourner toute votre stack. Les deux mondes finiront par converger, mais cela prendra encore quelques années — d'ici là, vous savez choisir le bon mode pour votre projet.
Concrètement, le pattern à adopter sur votre prochain projet : commencez par identifier les fonctionnalités transversales qui s'appliquent à plusieurs méthodes (audit, retry, validation, cache, mesure de performance). Chacune est un candidat à devenir un décorateur réutilisable. Une fois la lib interne de décorateurs en place, votre code applicatif devient drastiquement plus lisible — les méthodes ne contiennent plus que la logique métier, le reste est annoté au-dessus. C'est un investissement à fort levier qui paie dès les premières dizaines d'usages.
- Stage 3 par défaut pour les projets neufs sans Angular/NestJS
- Legacy + reflect-metadata pour Angular, NestJS, TypeORM, class-validator
- Préserver la signature TypeScript du wrapper (génériques This/Args/Return)
- Toujours utiliser des factories pour les décorateurs paramétrables
- Documenter chaque décorateur custom comme une API publique
- Tester chaque décorateur en isolation avec Vitest
- Limiter à 3 décorateurs par méthode — au-delà, refactoriser
- Privilégier les patterns connus (log, memoize, retry, debounce) aux nouveautés exotiques
- Utiliser
accessor+ accessor decorator au lieu de property decorator en Stage 3 - Ne pas confondre métadonnées (Reflect) et comportement (wrapper) — chaque décorateur fait l'un ou l'autre