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.metadatapour 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é
accessorpour intercepter get/set Symbol.metadataremplaceReflect.metadatapour 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
- TC39 Decorators Proposal par TC39
- TypeScript 5.0 Decorators par Microsoft
- A Complete Guide to TypeScript Decorators par Matt Pocock