Décorateurs#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Les décorateurs sont l’une des fonctionnalités les plus élégantes de TypeScript pour la métaprogrammation. Ils permettent d’enrichir ou de modifier des classes, des méthodes, des propriétés et des paramètres au moment de la définition, sans altérer le code source original. On les retrouve massivement dans les grands frameworks TypeScript — Angular, NestJS, TypeORM, MobX — où ils servent de DSL déclaratif pour configurer des composants, des routes ou des entités de base de données. Comprendre les décorateurs, c’est comprendre le paradigme qui sous-tend tout un écosystème.

Qu’est-ce qu’un décorateur ?#

Un décorateur est une fonction ordinaire qui reçoit des informations sur l’élément qu’elle décore et peut le modifier. Sa particularité est syntaxique : placé juste avant la déclaration qu’il cible, précédé du symbole @, il s’applique automatiquement à cet élément sans que l’appelant ait à modifier son code.

@MonDécorateur
class MaClasse {
  @PropriétéDécorée
  valeur: string = "";

  @MéthodeDecorée
  maMethod(@ParamDécoré param: string): void { }
}

Activation dans tsconfig.json#

Les décorateurs ne sont pas activés par défaut. Il faut configurer le compilateur en conséquence :

{
  "compilerOptions": {
    "target": "ES2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  • experimentalDecorators : active la syntaxe @Décorateur (mode legacy, qui est le plus répandu aujourd’hui).

  • emitDecoratorMetadata : émet des métadonnées de type à l’exécution (nécessite le paquet reflect-metadata).

Les deux propositions#

Définition 31 (Décorateurs legacy (mode expérimental))

Les décorateurs legacy (experimentalDecorators: true) sont basés sur une ancienne proposition TC39 qui n’a jamais atteint le stade de standard. Ils sont néanmoins extrêmement répandus car c’est sur eux que s’appuient Angular, NestJS et TypeORM. Leur comportement peut varier selon les versions de TypeScript et certains détails ne correspondent plus au standard en cours de ratification.

Définition 32 (Décorateurs TC39 Stage 3 (mode standard))

Les décorateurs TC39 Stage 3 sont activés sans experimentalDecorators dans TypeScript 5.0+. Ils correspondent à la proposition de décorateurs qui a atteint le stade 3 du processus de standardisation ECMAScript. Leur signature est différente du mode legacy et ils offrent un modèle plus cohérent et prévisible. Les nouveaux projets devraient les préférer.

Dans ce chapitre, nous nous concentrons sur le mode legacy, qui est le plus courant dans les bases de code existantes, avant d’aborder brièvement la migration vers le mode standard en fin de chapitre.

Décorateurs de classe#

Un décorateur de classe reçoit le constructeur de la classe comme unique argument. Il peut retourner un nouveau constructeur pour remplacer la classe, ou undefined pour la modifier en place.

// Signature d'un décorateur de classe
type DécorateurClasse = (constructeur: Function) => Function | void;

@sealed — Sceller une classe#

function sealed(constructeur: Function): void {
  // Empêche l'ajout de nouvelles propriétés au constructeur et à son prototype
  Object.seal(constructeur);
  Object.seal(constructeur.prototype);
}

@sealed
class BureauDeChange {
  taux: number = 1.0;

  convertir(montant: number): number {
    return montant * this.taux;
  }
}

// Désormais, il est impossible d'ajouter des propriétés à BureauDeChange
// ou de modifier son prototype à l'exécution

@singleton — Garantir une instance unique#

function singleton<T extends { new (...args: unknown[]): object }>(
  constructeur: T
): T {
  let instance: InstanceType<T> | null = null;

  return class extends constructeur {
    constructor(...args: unknown[]) {
      if (instance) return instance as InstanceType<T>;
      super(...args);
      instance = this as unknown as InstanceType<T>;
    }
  } as T;
}

@singleton
class ConfigurationApp {
  readonly baseUrl: string;

  constructor() {
    this.baseUrl = process.env.BASE_URL ?? "http://localhost:3000";
  }
}

const c1 = new ConfigurationApp();
const c2 = new ConfigurationApp();
console.log(c1 === c2); // true — une seule instance existe

Décorateurs de méthode#

Un décorateur de méthode reçoit trois arguments : la cible (target), le nom de la propriété (propertyKey) et le descripteur de propriété (descriptor). Ce descripteur contient la fonction originale sous descriptor.value et peut être modifié pour y substituer une nouvelle fonction.

type DécorateurMéthode = (
  target: object,
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor
) => PropertyDescriptor | void;

@log — Journaliser les appels#

function log(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const méthodeOriginale = descriptor.value as Function;

  descriptor.value = function (...args: unknown[]) {
    console.log(`[LOG] Appel de ${propertyKey}(${JSON.stringify(args)})`);
    const résultat = méthodeOriginale.apply(this, args);
    console.log(`[LOG] ${propertyKey} a retourné : ${JSON.stringify(résultat)}`);
    return résultat;
  };

  return descriptor;
}

class CalculatriceFinancière {
  @log
  calculerIntérêts(capital: number, taux: number, durée: number): number {
    return capital * taux * durée;
  }
}

const calc = new CalculatriceFinancière();
calc.calculerIntérêts(1000, 0.05, 2);
// [LOG] Appel de calculerIntérêts([1000,0.05,2])
// [LOG] calculerIntérêts a retourné : 100

@memoize — Mise en cache des résultats#

function memoize(
  _target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const méthodeOriginale = descriptor.value as Function;
  const cache = new Map<string, unknown>();

  descriptor.value = function (...args: unknown[]) {
    const clé = JSON.stringify(args);
    if (cache.has(clé)) {
      console.log(`[CACHE HIT] ${propertyKey}(${clé})`);
      return cache.get(clé);
    }
    const résultat = méthodeOriginale.apply(this, args);
    cache.set(clé, résultat);
    return résultat;
  };

  return descriptor;
}

class ServiceFibonacci {
  @memoize
  calculer(n: number): number {
    if (n <= 1) return n;
    return this.calculer(n - 1) + this.calculer(n - 2);
  }
}

@debounce — Retarder l’exécution#

function debounce(délaiMs: number) {
  return function (
    _target: object,
    _propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const méthodeOriginale = descriptor.value as Function;
    let minuterie: ReturnType<typeof setTimeout> | null = null;

    descriptor.value = function (...args: unknown[]) {
      if (minuterie) clearTimeout(minuterie);
      minuterie = setTimeout(() => {
        méthodeOriginale.apply(this, args);
      }, délaiMs);
    };

    return descriptor;
  };
}

class BarreRecherche {
  @debounce(300)
  rechercherProduits(requête: string): void {
    // Cet appel n'est effectué qu'après 300 ms sans nouvelle frappe
    console.log(`Recherche : ${requête}`);
  }
}

Décorateurs de propriété#

Un décorateur de propriété reçoit la cible et le nom de la propriété, mais pas de descripteur — la valeur initiale n’est pas encore disponible lors de la décoration. Pour modifier le comportement, on doit définir le descripteur manuellement via Object.defineProperty.

type DécorateurPropriété = (
  target: object,
  propertyKey: string | symbol
) => void;

@readonly — Propriété en lecture seule à l’exécution#

function readonly(target: object, propertyKey: string): void {
  Object.defineProperty(target, propertyKey, {
    writable: false,
    configurable: false,
  });
}

class Constante {
  @readonly
  PI: number = 3.14159265358979;
}

const c = new Constante();
// c.PI = 3; // Erreur à l'exécution en mode strict

@validate — Validation de valeur#

function validate(min: number, max: number) {
  return function (target: object, propertyKey: string): void {
    let valeur: number;

    Object.defineProperty(target, propertyKey, {
      get() { return valeur; },
      set(nouvelleValeur: number) {
        if (nouvelleValeur < min || nouvelleValeur > max) {
          throw new RangeError(
            `${propertyKey} doit être compris entre ${min} et ${max}. ` +
            `Reçu : ${nouvelleValeur}`
          );
        }
        valeur = nouvelleValeur;
      },
      enumerable: true,
      configurable: true,
    });
  };
}

class CapteurTempérature {
  @validate(-273.15, 1000)
  température: number = 20;
}

const capteur = new CapteurTempérature();
capteur.température = 36.6; // OK
// capteur.température = -500; // RangeError !

Décorateurs de paramètre#

Un décorateur de paramètre reçoit la cible, le nom de la méthode et l”indice du paramètre dans la liste des paramètres. Il ne peut pas modifier la valeur du paramètre directement ; son rôle principal est d”enregistrer des métadonnées (par exemple avec reflect-metadata) qui seront exploitées par d’autres décorateurs.

type DécorateurParamètre = (
  target: object,
  propertyKey: string | symbol,
  parameterIndex: number
) => void;
import "reflect-metadata";

const CLÉ_INJECTION = Symbol("injection");

function Inject(jeton: string) {
  return function (
    target: object,
    propertyKey: string | symbol,
    parameterIndex: number
  ): void {
    const injections: Record<number, string> =
      Reflect.getOwnMetadata(CLÉ_INJECTION, target, propertyKey) ?? {};
    injections[parameterIndex] = jeton;
    Reflect.defineMetadata(CLÉ_INJECTION, injections, target, propertyKey);
  };
}

class ContrôleurUtilisateur {
  obtenirProfil(
    @Inject("ServiceUtilisateur") service: unknown,
    @Inject("Logger") logger: unknown
  ): void {
    // Les métadonnées d'injection sont disponibles via Reflect
  }
}

Usines de décorateurs#

Une usine de décorateur (decorator factory) est une fonction qui retourne un décorateur. Elle permet de paramétrer le comportement du décorateur à l’aide d’arguments passés lors de l’application.

// Usine de décorateur — la fonction externe reçoit les paramètres
function Log(options: { niveau: "info" | "warn" | "error"; préfixe?: string }) {
  // Le décorateur réel est retourné
  return function (
    target: object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const méthodeOriginale = descriptor.value as Function;
    const préfixe = options.préfixe ?? propertyKey;

    descriptor.value = function (...args: unknown[]) {
      const message = `[${options.niveau.toUpperCase()}] ${préfixe}: ${JSON.stringify(args)}`;
      console[options.niveau](message);
      return méthodeOriginale.apply(this, args);
    };

    return descriptor;
  };
}

class ServiceCommande {
  @Log({ niveau: "info", préfixe: "CréerCommande" })
  créerCommande(data: unknown): void { /* ... */ }

  @Log({ niveau: "warn" })
  annulerCommande(id: string): void { /* ... */ }
}

Remarque 23

On distingue soigneusement la syntaxe d’application selon qu’on utilise un décorateur simple ou une usine :

@Décorateur        // Décorateur simple — pas d'appel de fonction
@Usine("param")    // Usine — on appelle la fonction qui retourne le décorateur
class MaClasse {}

Toute erreur à ce niveau produit des messages cryptiques car TypeScript traite le résultat de l’expression comme un décorateur : si une usine est appliquée sans parenthèses, TypeScript tentera d’utiliser la fonction elle-même comme décorateur de classe, ce qui échouera silencieusement ou produira des comportements inattendus.


## Décorateurs et métadonnées

Le paquet `reflect-metadata` ajoute une API de réflexion qui permet de lire et d'écrire des métadonnées sur les classes et leurs membres. Combiné à `emitDecoratorMetadata`, TypeScript émet automatiquement le type de chaque paramètre et propriété dans les métadonnées.

```typescript
import "reflect-metadata";

// TypeScript émet automatiquement les types des paramètres du constructeur
@Injectable()
class ServicePaiement {
  constructor(
    private readonly repo: DépôtTransaction,
    private readonly logger: ServiceJournal
  ) {}
}

// Un conteneur IoC peut lire les types injectés :
const typesDépendances = Reflect.getMetadata(
  "design:paramtypes",
  ServicePaiement
);
// [DépôtTransaction, ServiceJournal]

Usage dans NestJS, TypeORM et Angular#

Ces frameworks exploitent massivement les métadonnées :

// NestJS — définir une route REST
@Controller("utilisateurs")
export class ContrôleurUtilisateurs {
  @Get(":id")
  async obtenirUtilisateur(@Param("id") id: string): Promise<Utilisateur> {
    return this.service.trouverParId(id);
  }
}

// TypeORM — cartographier une entité de base de données
@Entity("produits")
export class Produit {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: "varchar", length: 200 })
  nom: string;

  @Column({ type: "decimal", precision: 10, scale: 2 })
  prix: number;
}

// Angular — déclarer un service injectable
@Injectable({ providedIn: "root" })
export class ServiceDonnées {
  constructor(private readonly http: HttpClient) {}
}

Les décorateurs TC39 Stage 3#

TypeScript 5.0 a introduit la prise en charge des décorateurs selon la proposition TC39 Stage 3, sans nécessiter experimentalDecorators. Ce mode standard diffère du mode legacy sur plusieurs points importants.

Différences clés avec le mode legacy#

// Mode standard TC39 — décorateur de classe
function sealed(Classe: typeof MaClasse, ctx: ClassDecoratorContext) {
  // ctx.kind === "class"
  // ctx.name === "MaClasse"
  Object.seal(Classe);
  Object.seal(Classe.prototype);
}

// Mode standard — décorateur de méthode
function log<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  ctx: ClassMethodDecoratorContext<This, typeof target>
) {
  return function (this: This, ...args: Args): Return {
    console.log(`Appel de ${String(ctx.name)}`);
    return target.apply(this, args);
  };
}

Les différences majeures sont :

  • Les décorateurs TC39 reçoivent un objet de contexte (ctx) au lieu d’un ensemble de paramètres distincts.

  • Ils ne peuvent plus directement modifier le descripteur de propriété ; ils retournent une valeur de remplacement.

  • Ils n’ont pas accès à emitDecoratorMetadata ; la réflexion de type se fera via la future API Symbol.metadata.

  • Les décorateurs de propriété TC39 reçoivent un contexte de type ClassFieldDecoratorContext.

Migration#

Pour migrer un projet de legacy vers standard :

  1. Retirer experimentalDecorators et emitDecoratorMetadata de tsconfig.json.

  2. Réécrire chaque décorateur pour qu’il accepte le nouveau contexte.

  3. Remplacer reflect-metadata par une solution alternative (les métadonnées natives arriveront avec Symbol.metadata).

  4. Tester minutieusement les frameworks tiers — NestJS, TypeORM et Angular ne supportent pas encore tous le mode standard.

Exemple 11 (Décorateur standard vs legacy)

La même fonctionnalité de journalisation exprimée dans les deux modes :

// ---- Mode legacy ----
function logLegacy(
  target: object,
  key: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const fn = descriptor.value as Function;
  descriptor.value = function (...args: unknown[]) {
    console.log(`[legacy] ${key}`);
    return fn.apply(this, args);
  };
  return descriptor;
}

// ---- Mode standard TC39 ----
function logStandard<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  ctx: ClassMethodDecoratorContext
): typeof target {
  return function (this: This, ...args: Args): Return {
    console.log(`[standard] ${String(ctx.name)}`);
    return target.apply(this, args);
  };
}

```{code-cell} python
:tags: [hide-input]

fig, ax = plt.subplots(figsize=(13, 8))
ax.set_xlim(0, 13)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Ordre d'exécution des décorateurs", fontsize=16,
             fontweight='bold', pad=20)

palette = sns.color_palette("muted", 8)
bleu   = palette[0]
orange = palette[1]
vert   = palette[2]
rouge  = palette[3]
violet = palette[4]

# ---- Colonne gauche : déclaration (de haut en bas) ----
déclarations = [
    ("@Log({ niveau: 'warn' })",  "usine 1 — évaluée en 1er", orange),
    ("@Log({ niveau: 'info' })",  "usine 2 — évaluée en 2e",  orange),
    ("@Memoize",                  "décorateur simple",         bleu),
    ("maMethode()",               "méthode décorée",           vert),
]

ax.text(0.3, 9.4, 'Ordre de déclaration', fontsize=12,
        fontweight='bold', color='#333333')
ax.text(0.3, 9.0, '(de haut en bas dans le code)', fontsize=9,
        color='#666666', style='italic')

for i, (label, desc, couleur) in enumerate(déclarations):
    y = 7.8 - i * 1.4
    box = patches.FancyBboxPatch(
        (0.2, y - 0.32), 4.8, 0.64,
        boxstyle="round,pad=0.1", linewidth=1.8,
        edgecolor=couleur, facecolor=couleur, alpha=0.15)
    ax.add_patch(box)
    ax.text(0.55, y + 0.08,
            label, ha='left', va='center',
            fontsize=10, fontweight='bold', color='#222222',
            fontfamily='monospace')
    ax.text(0.55, y - 0.20,
            desc, ha='left', va='center',
            fontsize=8, color='#666666', style='italic')

# ---- Séparateur ----
ax.axvline(x=6.5, ymin=0.05, ymax=0.95,
           color='#cccccc', linewidth=1.5, linestyle='--')

# ---- Colonne droite : pile d'exécution (de bas en haut) ----
exécutions = [
    ("Usine 1 → décorateur(target, key, desc)",
     "⑤ exécuté en dernier",  orange),
    ("Usine 2 → décorateur(target, key, desc)",
     "④ exécuté en 4e",       orange),
    ("@Memoize(target, key, desc)",
     "③ exécuté en 3e",       bleu),
    ("Usines évaluées : factory2()",
     "② factory 2 appelée",   orange),
    ("Usines évaluées : factory1()",
     "① factory 1 appelée",   orange),
]

ax.text(6.8, 9.4, "Pile d'exécution réelle", fontsize=12,
        fontweight='bold', color='#333333')
ax.text(6.8, 9.0, '(usines évaluées haut→bas ; décorateurs appliqués bas→haut)',
        fontsize=9, color='#666666', style='italic')

for i, (label, numéro, couleur) in enumerate(exécutions):
    y = 7.8 - i * 1.4
    box = patches.FancyBboxPatch(
        (6.7, y - 0.32), 5.9, 0.64,
        boxstyle="round,pad=0.1", linewidth=1.8,
        edgecolor=couleur, facecolor=couleur, alpha=0.15)
    ax.add_patch(box)
    ax.text(7.0, y + 0.08,
            label, ha='left', va='center',
            fontsize=9, fontweight='bold', color='#222222',
            fontfamily='monospace')
    ax.text(7.0, y - 0.20,
            numéro, ha='left', va='center',
            fontsize=8, color='#666666', style='italic')

# ---- Note de bas de page ----
ax.text(6.5, 0.6,
        'Les décorateurs sont appliqués de bas en haut (bas de la pile → haut).\n'
        'Les usines de décorateurs sont évaluées de haut en bas (ordre de déclaration).',
        ha='center', va='center', fontsize=9, color='#555555',
        style='italic',
        bbox=dict(boxstyle='round,pad=0.4', facecolor='#fff9e6',
                  edgecolor='#ddbb00', alpha=0.8))

plt.tight_layout()
plt.show()

Résumé#

Dans ce chapitre, nous avons exploré les décorateurs, le mécanisme de métaprogrammation de TypeScript :

  • Un décorateur est une fonction ordinaire placée avant une déclaration avec @, qui peut inspecter et modifier des classes, méthodes, propriétés et paramètres sans altérer leur code source.

  • Deux modes coexistent : le mode legacy (experimentalDecorators: true), universel dans les frameworks actuels, et le mode TC39 Stage 3, le futur standard introduit dans TypeScript 5.0.

  • Les décorateurs de classe reçoivent le constructeur et peuvent le remplacer entièrement (@singleton) ou le sceller (@sealed).

  • Les décorateurs de méthode reçoivent la cible, le nom et le descripteur de propriété, ce qui permet de substituer la fonction originale par une version enrichie (@log, @memoize, @debounce).

  • Les décorateurs de propriété doivent utiliser Object.defineProperty pour redéfinir les accesseurs (@readonly, @validate).

  • Les décorateurs de paramètre enregistrent des métadonnées (avec reflect-metadata) exploitées par des conteneurs d’injection de dépendances.

  • Les usines de décorateurs permettent de paramétrer le comportement avec des arguments : @Log({ niveau: 'info' }).

  • Les frameworks comme NestJS, TypeORM et Angular s’appuient intensément sur les décorateurs et reflect-metadata pour offrir des API déclaratives et expressives.

Le chapitre suivant aborde la gestion des modules et des namespaces en TypeScript : comment organiser son code en unités réutilisables, résoudre les dépendances et écrire des déclarations de types pour des modules externes.