TypeScript en pratique - 09 - Decorateurs natifs (Stage 3)

Les decorateurs natifs de TypeScript 5+ : class, method, field et accessor decorators avec des cas concrets de logging, validation et caching.

09 - Decorateurs natifs (Stage 3)

Ce que tu vas apprendre

  • La différence entre les decorateurs legacy (experimentalDecorators) et les decorateurs Stage 3
  • Comment fonctionnent les class, method, field et accessor decorators
  • L'utilisation de Symbol.metadata pour attacher des metadonnees
  • Des cas concrets : logging, validation, caching, timing
  • Comment NestJS et Angular utilisent les decorateurs

Prerequisites

Avoir lu l'article sur les ORMs et TypeScript.


Deux generations de decorateurs

Si tu as deja utilise Angular ou NestJS, tu connais les decorateurs. Mais ceux que tu as utilises sont les decorateurs legacy, actives par experimentalDecorators: true dans le tsconfig. Ils suivent une proposition qui n'a jamais dépassé le Stage 2.

TypeScript 5.0 a introduit les decorateurs Stage 3, qui suivent la proposition TC39 acceptee. Les deux syntaxes coexistent mais ne sont pas compatibles entre elles. Sur un nouveau projet, il n'y a aucune raison d'utiliser les legacy.

jsonc// tsconfig.json — NE PAS mettre experimentalDecorators
{
  "compilerOptions": {
    "target": "ES2022"
    // pas de "experimentalDecorators": true
  }
}

Si experimentalDecorators est absent ou false, TypeScript utilise les decorateurs Stage 3.

Un decorateur, c'est une fonction

Un decorateur est une fonction qui recoit la valeur decoree et un objet de contexte, et qui retourne optionnellement une nouvelle valeur.

typescripttype Decorator = (value: Input, context: DecoratorContext) => Output | void

Le contexte contient des informations sur ce qui est decore :

typescriptinterface DecoratorContext {
  kind: "class" | "method" | "getter" | "setter" | "field" | "accessor"
  name: string | symbol
  static: boolean
  private: boolean
  access: { get(): unknown; set(value: unknown): void }
  addInitializer(initializer: () => void): void
  metadata: Record<string | number | symbol, unknown>
}

Method decorators

Le cas le plus courant. Un method decorator recoit la fonction originale et retourne une fonction de remplacement (ou rien pour garder l'originale).

Logging

typescriptfunction log(
  target: Function,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name)

  function replacement(this: unknown, ...args: unknown[]) {
    console.log(`-> ${methodName}(${args.map(a => JSON.stringify(a)).join(", ")})`)
    const result = target.call(this, ...args)
    console.log(`<- ${methodName} = ${JSON.stringify(result)}`)
    return result
  }

  return replacement
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b
  }
}

const calc = new Calculator()
calc.add(2, 3)
// -> add(2, 3)
// <- add = 5

Timing

typescriptfunction timed(
  target: Function,
  context: ClassMethodDecoratorContext
) {
  const name = String(context.name)

  return function (this: unknown, ...args: unknown[]) {
    const start = performance.now()
    const result = target.call(this, ...args)
    const duration = performance.now() - start
    console.log(`${name} a pris ${duration.toFixed(2)}ms`)
    return result
  }
}

class ImageProcessor {
  @timed
  resize(width: number, height: number) {
    // traitement lourd...
  }
}

J'utilise ce pattern sur paltemps.fr pour mesurer le temps de certaines opérations cote serveur sans polluer le code métier.

Caching

typescriptfunction cached(
  target: Function,
  context: ClassMethodDecoratorContext
) {
  const cache = new Map<string, unknown>()

  return function (this: unknown, ...args: unknown[]) {
    const key = JSON.stringify(args)
    if (cache.has(key)) {
      return cache.get(key)
    }
    const result = target.call(this, ...args)
    cache.set(key, result)
    return result
  }
}

class UserService {
  @cached
  getUserPermissions(userId: string): string[] {
    // requete lourde...
    return ["read", "write"]
  }
}

Le cache est par instance de decorateur, pas par instance de classe. Si tu veux un cache par instance, utilise addInitializer pour attacher le cache a this dans le constructeur.

Class decorators

Un class decorator recoit le constructeur et peut retourner une nouvelle classe.

typescriptfunction sealed(
  target: Function,
  context: ClassDecoratorContext
) {
  Object.seal(target)
  Object.seal(target.prototype)
}

@sealed
class Config {
  host = "localhost"
  port = 3000
}

Un cas plus utile : enregistrer automatiquement les classes dans un registre.

typescriptconst registry = new Map<string, new (...args: unknown[]) => unknown>()

function register(
  target: new (...args: unknown[]) => unknown,
  context: ClassDecoratorContext
) {
  registry.set(String(context.name), target)
}

@register
class EmailNotifier {
  send(message: string) {
    console.log(`Email: ${message}`)
  }
}

@register
class SmsNotifier {
  send(message: string) {
    console.log(`SMS: ${message}`)
  }
}

// registry contient "EmailNotifier" et "SmsNotifier"

Field decorators

Les field decorators ne recoivent pas la valeur du champ (elle n'existe pas encore au moment de la decoration). Ils recoivent undefined et peuvent retourner une fonction d'initialisation.

typescriptfunction upperCase(
  _value: undefined,
  context: ClassFieldDecoratorContext
) {
  return function (this: unknown, initialValue: string) {
    return initialValue.toUpperCase()
  }
}

class Settings {
  @upperCase
  environment = "production"
}

const s = new Settings()
console.log(s.environment) // "PRODUCTION"

Accessor decorators

TypeScript 5.0 a ajoute le mot-clé accessor pour les champs de classe. Un accessor decorator recoit un objet { get, set } et retourne un nouvel objet { get, set }.

typescriptfunction clamp(min: number, max: number) {
  return function (
    target: ClassAccessorDecoratorTarget<unknown, number>,
    context: ClassAccessorDecoratorContext<unknown, number>
  ): ClassAccessorDecoratorResult<unknown, number> {
    return {
      set(value: number) {
        const clamped = Math.min(Math.max(value, min), max)
        target.set.call(this, clamped)
      },
      get() {
        return target.get.call(this)
      }
    }
  }
}

class Slider {
  @clamp(0, 100)
  accessor value = 50
}

const slider = new Slider()
slider.value = 150
console.log(slider.value) // 100
slider.value = -20
console.log(slider.value) // 0

Le mot-clé accessor généré un getter et un setter prives. Le decorateur intercepte ces accesseurs.

Metadata avec Symbol.metadata

Les decorateurs Stage 3 supportent Symbol.metadata pour attacher des metadonnees aux classes.

typescriptfunction validate(schema: { min?: number; max?: number }) {
  return function (
    _value: undefined,
    context: ClassFieldDecoratorContext
  ) {
    context.metadata[context.name] ??= {}
    ;(context.metadata[context.name] as Record<string, unknown>).validation = schema
  }
}

class Product {
  @validate({ min: 0 })
  price = 0

  @validate({ min: 1, max: 255 })
  name = ""
}

// Lire les metadonnees
const meta = Product[Symbol.metadata]
console.log(meta)
// { price: { validation: { min: 0 } }, name: { validation: { min: 1, max: 255 } } }

C'est le mecanisme qui remplace Reflect.metadata des decorateurs legacy. Il est standardise et ne depend pas de polyfills.

Decorateurs paramètres (factory pattern)

Pour passer des arguments a un decorateur, on utilise une fonction qui retourne le decorateur :

typescriptfunction retry(attempts: number, delayMs: number) {
  return function (
    target: Function,
    context: ClassMethodDecoratorContext
  ) {
    return async function (this: unknown, ...args: unknown[]) {
      for (let i = 0; i < attempts; i++) {
        try {
          return await target.call(this, ...args)
        } catch (error) {
          if (i === attempts - 1) throw error
          await new Promise(r => setTimeout(r, delayMs))
        }
      }
    }
  }
}

class ApiClient {
  @retry(3, 1000)
  async fetchData(url: string) {
    const res = await fetch(url)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  }
}

Composition de decorateurs

Les decorateurs s'appliquent du bas vers le haut (le plus proche de la méthode s'exécuté en premier) :

typescriptclass Service {
  @log
  @timed
  @retry(3, 500)
  async processOrder(orderId: string) {
    // ...
  }
}

// Ordre d'execution :
// 1. retry (le plus proche)
// 2. timed
// 3. log (le plus externe)

Comparaison avec les decorateurs legacy

Aspect Legacy Stage 3
Activation experimentalDecorators: true Par défaut (TS 5+)
Paramètres target, key, descriptor value, context
Metadata Reflect.metadata (polyfill) Symbol.metadata (natif)
Field decorators Recoivent le descriptor Recoivent undefined
Accessor Via property descriptor Mot-clé accessor dédié
Standard Jamais standardise TC39 Stage 3

NestJS et Angular utilisent encore les decorateurs legacy. La migration est en cours pour les deux frameworks. Si tu travailles avec ces frameworks, garde experimentalDecorators: true. Pour tout le reste, utilise les Stage 3.

Quand utiliser les decorateurs

Les decorateurs sont bien pour les preoccupations transversales : logging, caching, validation, autorisation, retry. Ils sont moins bien quand la logique decoree est spécifique a un seul endroit.

Mon conseil : commence par des fonctions simples (higher-order functions). Passe aux decorateurs quand tu as le meme pattern applique a beaucoup de méthodes dans un projet.


Résumé

  • Les decorateurs Stage 3 sont actifs par défaut dans TypeScript 5+ (sans experimentalDecorators)
  • Un decorateur est une fonction (value, context) => replacement | void
  • Les method decorators sont les plus utiles : logging, caching, timing, retry
  • Les accessor decorators utilisent le mot-clé accessor pour intercepter get/set
  • Symbol.metadata remplace Reflect.metadata pour les metadonnees standardisees
  • NestJS/Angular utilisent encore les legacy, mais la migration est en cours

Article précédent : 08 - Types avec les ORMs

Article suivant : 10 - Generics contraints dans les libs

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.