Découvrez la puissance des décorateurs TypeScript pour la programmation par métadonnées, orientée aspect et l'amélioration du code via des patrons déclaratifs. Un guide complet.
Décorateurs TypeScript : Maîtriser les Patrons de Programmation par Métadonnées pour des Applications Robustes
Dans le vaste paysage du développement logiciel moderne, maintenir des bases de code propres, évolutives et gérables est primordial. TypeScript, avec son système de types puissant et ses fonctionnalités avancées, fournit aux développeurs les outils pour y parvenir. Parmi ses fonctionnalités les plus intrigantes et transformatrices se trouvent les Décorateurs. Bien qu'il s'agisse encore d'une fonctionnalité expérimentale au moment de la rédaction de cet article (proposition de stade 3 pour ECMAScript), les décorateurs sont largement utilisés dans des frameworks comme Angular et TypeORM, changeant fondamentalement notre approche des patrons de conception, de la programmation par métadonnées et de la programmation orientée aspect (POA).
Ce guide complet plongera au cœur des décorateurs TypeScript, explorant leurs mécanismes, leurs différents types, leurs applications pratiques et les meilleures pratiques. Que vous construisiez des applications d'entreprise à grande échelle, des microservices ou des interfaces web côté client, la compréhension des décorateurs vous permettra d'écrire un code TypeScript plus déclaratif, maintenable et puissant.
Comprendre le Concept Fondamental : Qu'est-ce qu'un Décorateur ?
Au fond, un décorateur est un type spécial de déclaration qui peut être attaché à une déclaration de classe, une méthode, un accesseur, une propriété ou un paramètre. Les décorateurs sont des fonctions qui retournent une nouvelle valeur (ou modifient une valeur existante) pour la cible qu'ils décorent. Leur objectif principal est d'ajouter des métadonnées ou de modifier le comportement de la déclaration à laquelle ils sont attachés, sans modifier directement la structure du code sous-jacent. Cette manière externe et déclarative d'augmenter le code est incroyablement puissante.
Pensez aux décorateurs comme des annotations ou des étiquettes que vous appliquez à des parties de votre code. Ces étiquettes peuvent ensuite être lues ou utilisées par d'autres parties de votre application ou par des frameworks, souvent à l'exécution, pour fournir des fonctionnalités ou des configurations supplémentaires.
La Syntaxe d'un Décorateur
Les décorateurs sont préfixés par un symbole @
, suivi du nom de la fonction du décorateur. Ils sont placés juste avant la déclaration qu'ils décorent.
@MonDecorateur
class MaClasse {
@AutreDecorateur
maMethode() {
// ...
}
}
Activer les Décorateurs dans TypeScript
Avant de pouvoir utiliser les décorateurs, vous devez activer l'option du compilateur experimentalDecorators
dans votre fichier tsconfig.json
. De plus, pour les capacités avancées de réflexion des métadonnées (souvent utilisées par les frameworks), vous aurez également besoin de emitDecoratorMetadata
et du polyfill reflect-metadata
.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Vous devez également installer reflect-metadata
:
npm install reflect-metadata --save
# ou
yarn add reflect-metadata
Et importez-le tout en haut du point d'entrée de votre application (par exemple, main.ts
ou app.ts
) :
import "reflect-metadata";
// Votre code d'application suit
Fabriques de Décorateurs : La Personnalisation à Portée de Main
Bien qu'un décorateur de base soit une fonction, vous aurez souvent besoin de passer des arguments à un décorateur pour configurer son comportement. Ceci est réalisé en utilisant une fabrique de décorateurs. Une fabrique de décorateurs est une fonction qui retourne la fonction décorateur réelle. Lorsque vous appliquez une fabrique de décorateurs, vous l'appelez avec ses arguments, et elle retourne ensuite la fonction décorateur que TypeScript applique à votre code.
Exemple de Création d'une Fabrique de Décorateurs Simple
Créons une fabrique pour un décorateur Logger
qui peut enregistrer des messages avec différents préfixes.
function Logger(prefix: string) {
return function (target: Function) {
console.log(`[${prefix}] La classe ${target.name} a été définie.`);
};
}
@Logger("APP_INIT")
class ApplicationBootstrap {
constructor() {
console.log("L'application démarre...");
}
}
const app = new ApplicationBootstrap();
// Sortie :
// [APP_INIT] La classe ApplicationBootstrap a été définie.
// L'application démarre...
Dans cet exemple, Logger("APP_INIT")
est l'appel à la fabrique de décorateurs. Elle retourne la fonction décorateur réelle qui prend target: Function
(le constructeur de la classe) comme argument. Cela permet une configuration dynamique du comportement du décorateur.
Types de Décorateurs en TypeScript
TypeScript prend en charge cinq types de décorateurs distincts, chacun applicable à un type de déclaration spécifique. La signature de la fonction décorateur varie en fonction du contexte dans lequel elle est appliquée.
1. Décorateurs de Classe
Les décorateurs de classe sont appliqués aux déclarations de classe. La fonction décorateur reçoit le constructeur de la classe comme unique argument. Un décorateur de classe peut observer, modifier ou même remplacer une définition de classe.
Signature :
function ClassDecorator(target: Function) { ... }
Valeur de Retour :
Si le décorateur de classe retourne une valeur, celle-ci remplacera la déclaration de classe par la fonction constructeur fournie. C'est une fonctionnalité puissante, souvent utilisée pour les mixins ou l'augmentation de classe. Si aucune valeur n'est retournée, la classe d'origine est utilisée.
Cas d'Utilisation :
- Enregistrer des classes dans un conteneur d'injection de dépendances.
- Appliquer des mixins ou des fonctionnalités supplémentaires à une classe.
- Configurations spécifiques à un framework (par ex., le routage dans un framework web).
- Ajouter des hooks de cycle de vie aux classes.
Exemple de Décorateur de Classe : Injection d'un Service
Imaginez un scénario simple d'injection de dépendances où vous souhaitez marquer une classe comme "injectable" et éventuellement lui fournir un nom dans un conteneur.
const InjectableServiceRegistry = new Map<string, Function>();
function Injectable(name?: string) {
return function<T extends { new(...args: any[]): {} }>(constructor: T) {
const serviceName = name || constructor.name;
InjectableServiceRegistry.set(serviceName, constructor);
console.log(`Service enregistré : ${serviceName}`);
// Optionnellement, vous pourriez retourner une nouvelle classe ici pour augmenter le comportement
return class extends constructor {
createdAt = new Date();
// Propriétés ou méthodes supplémentaires pour tous les services injectés
};
};
}
@Injectable("UserService")
class UserDataService {
getUsers() {
return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
}
}
@Injectable()
class ProductDataService {
getProducts() {
return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];
}
}
console.log("--- Services Enregistrés ---");
console.log(Array.from(InjectableServiceRegistry.keys()));
const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
const userServiceInstance = new userServiceConstructor();
console.log("Utilisateurs :", userServiceInstance.getUsers());
// console.log("Service Utilisateur Créé le :", userServiceInstance.createdAt); // Si la classe retournée est utilisée
}
Cet exemple montre comment un décorateur de classe peut enregistrer une classe et même modifier son constructeur. Le décorateur Injectable
rend la classe découvrable par un système théorique d'injection de dépendances.
2. Décorateurs de Méthode
Les décorateurs de méthode sont appliqués aux déclarations de méthode. Ils reçoivent trois arguments : l'objet cible (pour les membres statiques, la fonction constructeur ; pour les membres d'instance, le prototype de la classe), le nom de la méthode et le descripteur de propriété de la méthode.
Signature :
function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Valeur de Retour :
Un décorateur de méthode peut retourner un nouveau PropertyDescriptor
. Si c'est le cas, ce descripteur sera utilisé pour définir la méthode. Cela vous permet de modifier ou de remplacer l'implémentation de la méthode originale, ce qui est incroyablement puissant pour la POA.
Cas d'Utilisation :
- Journaliser les appels de méthode et leurs arguments/résultats.
- Mettre en cache les résultats des méthodes pour améliorer les performances.
- Appliquer des contrôles d'autorisation avant l'exécution d'une méthode.
- Mesurer le temps d'exécution d'une méthode.
- Appliquer un debouncing ou un throttling aux appels de méthode.
Exemple de Décorateur de Méthode : Surveillance des Performances
Créons un décorateur MeasurePerformance
pour journaliser le temps d'exécution d'une méthode.
function MeasurePerformance(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
const start = process.hrtime.bigint();
const result = originalMethod.apply(this, args);
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1_000_000;
console.log(`La méthode "${propertyKey}" s'est exécutée en ${duration.toFixed(2)} ms`);
return result;
};
return descriptor;
}
class DataProcessor {
@MeasurePerformance
processData(data: number[]): number[] {
// Simule une opération complexe et gourmande en temps
for (let i = 0; i < 1_000_000; i++) {
Math.sin(i);
}
return data.map(n => n * 2);
}
@MeasurePerformance
fetchRemoteData(id: string): Promise<string> {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Données pour l'ID : ${id}`);
}, 500);
});
}
}
const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));
Le décorateur MeasurePerformance
enveloppe la méthode originale avec une logique de chronométrage, affichant la durée d'exécution sans encombrer la logique métier au sein de la méthode elle-même. C'est un exemple classique de Programmation Orientée Aspect (POA).
3. Décorateurs d'Accesseur
Les décorateurs d'accesseur sont appliqués aux déclarations d'accesseur (get
et set
). Similaires aux décorateurs de méthode, ils reçoivent l'objet cible, le nom de l'accesseur et son descripteur de propriété.
Signature :
function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Valeur de Retour :
Un décorateur d'accesseur peut retourner un nouveau PropertyDescriptor
, qui sera utilisé pour définir l'accesseur.
Cas d'Utilisation :
- Validation lors de la définition d'une propriété.
- Transformation d'une valeur avant qu'elle ne soit définie ou après qu'elle ait été récupérée.
- Contrôler les permissions d'accès pour les propriétés.
Exemple de Décorateur d'Accesseur : Mise en Cache des Getters
Créons un décorateur qui met en cache le résultat d'un calcul coûteux d'un getter.
function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalGetter = descriptor.get;
const cacheKey = `_cached_${String(propertyKey)}`;
if (originalGetter) {
descriptor.get = function() {
if (this[cacheKey] === undefined) {
console.log(`[Absence en Cache] Calcul de la valeur pour ${String(propertyKey)}`);
this[cacheKey] = originalGetter.apply(this);
} else {
console.log(`[Présence en Cache] Utilisation de la valeur en cache pour ${String(propertyKey)}`);
}
return this[cacheKey];
};
}
return descriptor;
}
class ReportGenerator {
private data: number[];
constructor(data: number[]) {
this.data = data;
}
// Simule un calcul coûteux
@CachedGetter
get expensiveSummary(): number {
console.log("Exécution du calcul coûteux du résumé...");
return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
}
}
const generator = new ReportGenerator([10, 20, 30, 40, 50]);
console.log("Premier accès :", generator.expensiveSummary);
console.log("Deuxième accès :", generator.expensiveSummary);
console.log("Troisième accès :", generator.expensiveSummary);
Ce décorateur garantit que le calcul du getter expensiveSummary
ne s'exécute qu'une seule fois, les appels suivants retournent la valeur mise en cache. Ce patron est très utile pour optimiser les performances lorsque l'accès à une propriété implique des calculs lourds ou des appels externes.
4. Décorateurs de Propriété
Les décorateurs de propriété sont appliqués aux déclarations de propriété. Ils reçoivent deux arguments : l'objet cible (pour les membres statiques, la fonction constructeur ; pour les membres d'instance, le prototype de la classe), et le nom de la propriété.
Signature :
function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }
Valeur de Retour :
Les décorateurs de propriété ne peuvent retourner aucune valeur. Leur utilisation principale est d'enregistrer des métadonnées sur la propriété. Ils ne peuvent pas changer directement la valeur de la propriété ou son descripteur au moment de la décoration, car le descripteur d'une propriété n'est pas encore entièrement défini lorsque les décorateurs de propriété sont exécutés.
Cas d'Utilisation :
- Enregistrer des propriétés pour la sérialisation/désérialisation.
- Appliquer des règles de validation aux propriétés.
- Définir des valeurs par défaut ou des configurations pour les propriétés.
- Mapping de colonnes ORM (Object-Relational Mapping) (par ex.,
@Column()
dans TypeORM).
Exemple de Décorateur de Propriété : Validation de Champ Obligatoire
Créons un décorateur pour marquer une propriété comme "obligatoire" puis la valider à l'exécution.
interface ValidationRule {
property: string | symbol;
validate: (value: any) => boolean;
message: string;
}
const validationRules: Map<Function, ValidationRule[]> = new Map();
function Required(target: Object, propertyKey: string | symbol) {
const rules = validationRules.get(target.constructor) || [];
rules.push({
property: propertyKey,
validate: (value: any) => value !== null && value !== undefined && value !== "",
message: `${String(propertyKey)} est requis.`
});
validationRules.set(target.constructor, rules);
}
function validate(instance: any): string[] {
const classRules = validationRules.get(instance.constructor) || [];
const errors: string[] = [];
for (const rule of classRules) {
if (!rule.validate(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
class UserProfile {
@Required
firstName: string;
@Required
lastName: string;
age?: number;
constructor(firstName: string, lastName: string, age?: number) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
const user1 = new UserProfile("John", "Doe", 30);
console.log("Erreurs de validation utilisateur 1 :", validate(user1)); // []
const user2 = new UserProfile("", "Smith");
console.log("Erreurs de validation utilisateur 2 :", validate(user2)); // ["firstName est requis."]
const user3 = new UserProfile("Alice", "");
console.log("Erreurs de validation utilisateur 3 :", validate(user3)); // ["lastName est requis."]
Le décorateur Required
enregistre simplement la règle de validation dans une map centrale validationRules
. Une fonction validate
séparée utilise ensuite ces métadonnées pour vérifier l'instance à l'exécution. Ce patron sépare la logique de validation de la définition des données, la rendant réutilisable et propre.
5. Décorateurs de Paramètre
Les décorateurs de paramètre sont appliqués aux paramètres dans un constructeur de classe ou une méthode. Ils reçoivent trois arguments : l'objet cible (pour les membres statiques, la fonction constructeur ; pour les membres d'instance, le prototype de la classe), le nom de la méthode (ou undefined
pour les paramètres de constructeur), et l'index ordinal du paramètre dans la liste des paramètres de la fonction.
Signature :
function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }
Valeur de Retour :
Les décorateurs de paramètre ne peuvent retourner aucune valeur. Comme les décorateurs de propriété, leur rôle principal est d'ajouter des métadonnées sur le paramètre.
Cas d'Utilisation :
- Enregistrer les types de paramètres pour l'injection de dépendances (par ex.,
@Inject()
dans Angular). - Appliquer une validation ou une transformation à des paramètres spécifiques.
- Extraire des métadonnées sur les paramètres de requête API dans les frameworks web.
Exemple de Décorateur de Paramètre : Injection de Données de Requête
Simulons comment un framework web pourrait utiliser des décorateurs de paramètre pour injecter des données spécifiques dans un paramètre de méthode, comme un ID utilisateur depuis une requête.
interface ParameterMetadata {
index: number;
key: string | symbol;
resolver: (request: any) => any;
}
const parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();
function RequestParam(paramName: string) {
return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
const targetKey = propertyKey || "constructor";
let methodResolvers = parameterResolvers.get(target.constructor);
if (!methodResolvers) {
methodResolvers = new Map();
parameterResolvers.set(target.constructor, methodResolvers);
}
const paramMetadata = methodResolvers.get(targetKey) || [];
paramMetadata.push({
index: parameterIndex,
key: targetKey,
resolver: (request: any) => request[paramName]
});
methodResolvers.set(targetKey, paramMetadata);
};
}
// Une fonction de framework hypothétique pour invoquer une méthode avec des paramètres résolus
function executeWithParams(instance: any, methodName: string, request: any) {
const classResolvers = parameterResolvers.get(instance.constructor);
if (!classResolvers) {
return (instance[methodName] as Function).apply(instance, []);
}
const methodParamMetadata = classResolvers.get(methodName);
if (!methodParamMetadata) {
return (instance[methodName] as Function).apply(instance, []);
}
const args: any[] = Array(methodParamMetadata.length);
for (const meta of methodParamMetadata) {
args[meta.index] = meta.resolver(request);
}
return (instance[methodName] as Function).apply(instance, args);
}
class UserController {
getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {
console.log(`Récupération de l'utilisateur avec ID : ${userId}, Jeton : ${authToken || "N/A"}`);
return { id: userId, name: "Jane Doe" };
}
deleteUser(@RequestParam("id") userId: string) {
console.log(`Suppression de l'utilisateur avec ID : ${userId}`);
return { status: "deleted", id: userId };
}
}
const userController = new UserController();
// Simuler une requête entrante
const mockRequest = {
id: "user123",
token: "abc-123",
someOtherProp: "xyz"
};
console.log("\n--- Exécution de getUser ---");
executeWithParams(userController, "getUser", mockRequest);
console.log("\n--- Exécution de deleteUser ---");
executeWithParams(userController, "deleteUser", { id: "user456" });
Cet exemple montre comment les décorateurs de paramètre peuvent recueillir des informations sur les paramètres de méthode requis. Un framework peut ensuite utiliser ces métadonnées collectées pour résoudre et injecter automatiquement les valeurs appropriées lorsque la méthode est appelée, simplifiant considérablement la logique du contrôleur ou du service.
Composition des Décorateurs et Ordre d'Exécution
Les décorateurs peuvent être appliqués dans diverses combinaisons, et comprendre leur ordre d'exécution est crucial pour prédire le comportement et éviter les problèmes inattendus.
Plusieurs Décorateurs sur une Seule Cible
Lorsque plusieurs décorateurs sont appliqués à une seule déclaration (par exemple, une classe, une méthode ou une propriété), ils s'exécutent dans un ordre spécifique : de bas en haut, ou de droite à gauche, pour leur évaluation. Cependant, leurs résultats sont appliqués dans l'ordre inverse.
@DecorateurA
@DecorateurB
class MaClasse {
// ...
}
Ici, DecorateurB
sera évalué en premier, puis DecorateurA
. S'ils modifient la classe (par exemple, en retournant un nouveau constructeur), la modification de DecorateurA
enveloppera ou s'appliquera par-dessus la modification de DecorateurB
.
Exemple : Chaînage de Décorateurs de Méthode
Considérez deux décorateurs de méthode : LogCall
et Authorization
.
function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG] Appel de ${String(propertyKey)} avec les arguments :`, args);
const result = originalMethod.apply(this, args);
console.log(`[LOG] La méthode ${String(propertyKey)} a retourné :`, result);
return result;
};
return descriptor;
}
function Authorization(roles: string[]) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const currentUserRoles = ["admin"]; // Simule la récupération des rôles de l'utilisateur actuel
const authorized = roles.some(role => currentUserRoles.includes(role));
if (!authorized) {
console.warn(`[AUTH] Accès refusé pour ${String(propertyKey)}. Rôles requis : ${roles.join(", ")}`);
throw new Error("Accès non autorisé");
}
console.log(`[AUTH] Accès accordé pour ${String(propertyKey)}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class SecureService {
@LogCall
@Authorization(["admin"])
deleteSensitiveData(id: string) {
console.log(`Suppression des données sensibles pour l'ID : ${id}`);
return `Données ID ${id} supprimées.`;
}
@Authorization(["user"])
@LogCall // Ordre changé ici
fetchPublicData(query: string) {
console.log(`Récupération des données publiques avec la requête : ${query}`);
return `Données publiques pour la requête : ${query}`;
}
}
const service = new SecureService();
try {
console.log("\n--- Appel de deleteSensitiveData (Utilisateur Admin) ---");
service.deleteSensitiveData("record123");
} catch (error: any) {
console.error(error.message);
}
try {
console.log("\n--- Appel de fetchPublicData (Utilisateur non-Admin) ---");
// Simule un utilisateur non-admin essayant d'accéder à fetchPublicData qui requiert le rôle 'user'
const mockUserRoles = ["guest"]; // Ceci échouera à l'authentification
// Pour rendre cela dynamique, vous auriez besoin d'un système d'ID ou d'un contexte statique pour les rôles de l'utilisateur actuel.
// Pour simplifier, nous supposons que le décorateur Authorization a accès au contexte de l'utilisateur actuel.
// Ajustons le décorateur Authorization pour toujours supposer 'admin' pour la démo,
// afin que le premier appel réussisse et le second échoue pour montrer les différents chemins.
// Ré-exécuter avec le rôle utilisateur pour que fetchPublicData réussisse.
// Imaginez que currentUserRoles dans Authorization devienne : ['user']
// Pour cet exemple, restons simple et montrons l'effet de l'ordre.
service.fetchPublicData("terme de recherche"); // Ceci exécutera Auth -> Log
} catch (error: any) {
console.error(error.message);
}
/* Sortie attendue pour deleteSensitiveData :
[AUTH] Accès accordé pour deleteSensitiveData
[LOG] Appel de deleteSensitiveData avec les arguments : [ 'record123' ]
Suppression des données sensibles pour l'ID : record123
[LOG] La méthode deleteSensitiveData a retourné : Données ID record123 supprimées.
*/
/* Sortie attendue pour fetchPublicData (si l'utilisateur a le rôle 'user') :
[LOG] Appel de fetchPublicData avec les arguments : [ 'terme de recherche' ]
[AUTH] Accès accordé pour fetchPublicData
Récupération des données publiques avec la requête : terme de recherche
[LOG] La méthode fetchPublicData a retourné : Données publiques pour la requête : terme de recherche
*/
Notez l'ordre : pour deleteSensitiveData
, Authorization
(en bas) s'exécute en premier, puis LogCall
(en haut) l'enveloppe. La logique interne de Authorization
s'exécute en premier. Pour fetchPublicData
, LogCall
(en bas) s'exécute en premier, puis Authorization
(en haut) l'enveloppe. Cela signifie que l'aspect LogCall
sera à l'extérieur de l'aspect Authorization
. Cette différence est critique pour les préoccupations transversales comme la journalisation ou la gestion des erreurs, où l'ordre d'exécution peut avoir un impact significatif sur le comportement.
Ordre d'Exécution pour Différentes Cibles
Quand une classe, ses membres et ses paramètres ont tous des décorateurs, l'ordre d'exécution est bien défini :
- Les Décorateurs de Paramètre sont appliqués en premier, pour chaque paramètre, en commençant du dernier paramètre au premier.
- Ensuite, les Décorateurs de Méthode, d'Accesseur ou de Propriété sont appliqués pour chaque membre.
- Enfin, les Décorateurs de Classe sont appliqués à la classe elle-même.
Au sein de chaque catégorie, plusieurs décorateurs sur la même cible sont appliqués de bas en haut (ou de droite à gauche).
Exemple : Ordre d'Exécution Complet
function log(message: string) {
return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
if (typeof descriptorOrIndex === 'number') {
console.log(`Décorateur de Paramètre : ${message} sur le paramètre #${descriptorOrIndex} de ${String(propertyKey || "constructor")}`);
} else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
console.log(`Décorateur de Méthode/Accesseur : ${message} sur ${String(propertyKey)}`);
} else {
console.log(`Décorateur de Propriété : ${message} sur ${String(propertyKey)}`);
}
} else {
console.log(`Décorateur de Classe : ${message} sur ${target.name}`);
}
return descriptorOrIndex; // Retourne le descripteur pour la méthode/accesseur, undefined pour les autres
};
}
@log("Classe Niveau D")
@log("Classe Niveau C")
class MyDecoratedClass {
@log("Propriété Statique A")
static staticProp: string = "";
@log("Propriété d'Instance B")
instanceProp: number = 0;
@log("Méthode D")
@log("Méthode C")
myMethod(
@log("Paramètre Z") paramZ: string,
@log("Paramètre Y") paramY: number
) {
console.log("Méthode myMethod exécutée.");
}
@log("Getter/Setter F")
get myAccessor() {
return "";
}
set myAccessor(value: string) {
//...
}
constructor() {
console.log("Constructeur exécuté.");
}
}
new MyDecoratedClass();
// Appel de la méthode pour déclencher le décorateur de méthode
new MyDecoratedClass().myMethod("hello", 123);
/* Ordre de Sortie Prédit (approximatif, selon la version spécifique de TypeScript et la compilation) :
Décorateur de Paramètre : Paramètre Y sur le paramètre #1 de myMethod
Décorateur de Paramètre : Paramètre Z sur le paramètre #0 de myMethod
Décorateur de Propriété : Propriété Statique A sur staticProp
Décorateur de Propriété : Propriété d'Instance B sur instanceProp
Décorateur de Méthode/Accesseur : Getter/Setter F sur myAccessor
Décorateur de Méthode/Accesseur : Méthode C sur myMethod
Décorateur de Méthode/Accesseur : Méthode D sur myMethod
Décorateur de Classe : Classe Niveau C sur MyDecoratedClass
Décorateur de Classe : Classe Niveau D sur MyDecoratedClass
Constructeur exécuté.
Méthode myMethod exécutée.
*/
Le moment exact des logs de la console peut varier légèrement en fonction du moment où un constructeur ou une méthode est invoqué, mais l'ordre dans lequel les fonctions décorateurs elles-mêmes sont exécutées (et donc leurs effets de bord ou valeurs de retour appliquées) suit les règles ci-dessus.
Applications Pratiques et Patrons de Conception avec les Décorateurs
Les décorateurs, surtout en conjonction avec le polyfill reflect-metadata
, ouvrent un nouveau domaine de programmation pilotée par les métadonnées. Cela permet des patrons de conception puissants qui abstraient le code répétitif et les préoccupations transversales.
1. Injection de Dépendances (ID)
L'une des utilisations les plus importantes des décorateurs se trouve dans les frameworks d'Injection de Dépendances (comme @Injectable()
, @Component()
, etc. d'Angular, ou l'utilisation extensive de l'ID par NestJS). Les décorateurs vous permettent de déclarer des dépendances directement sur les constructeurs ou les propriétés, permettant au framework d'instancier et de fournir automatiquement les services corrects.
Exemple : Injection de Service Simplifiée
import "reflect-metadata"; // Essentiel pour emitDecoratorMetadata
const INJECTABLE_METADATA_KEY = Symbol("injectable");
const INJECT_METADATA_KEY = Symbol("inject");
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
};
}
function Inject(token: any) {
return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];
existingInjections[parameterIndex] = token;
Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);
};
}
class Container {
private static instances = new Map<any, any>();
static resolve<T>(target: { new (...args: any[]): T }): T {
if (Container.instances.has(target)) {
return Container.instances.get(target);
}
const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
if (!isInjectable) {
throw new Error(`La classe ${target.name} n'est pas marquée comme @Injectable.`);
}
// Obtenir les types des paramètres du constructeur (nécessite emitDecoratorMetadata)
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];
const dependencies = paramTypes.map((paramType, index) => {
// Utiliser le jeton @Inject explicite s'il est fourni, sinon inférer le type
const token = explicitInjections[index] || paramType;
if (token === undefined) {
throw new Error(`Impossible de résoudre le paramètre à l'index ${index} pour ${target.name}. Il pourrait s'agir d'une dépendance circulaire ou d'un type primitif sans @Inject explicite.`);
}
return Container.resolve(token);
});
const instance = new target(...dependencies);
Container.instances.set(target, instance);
return instance;
}
}
// Définir les services
@Injectable()
class DatabaseService {
connect() {
console.log("Connexion à la base de données...");
return "Connexion BD";
}
}
@Injectable()
class AuthService {
private db: DatabaseService;
constructor(db: DatabaseService) {
this.db = db;
}
login() {
console.log(`AuthService : Authentification en utilisant ${this.db.connect()}`);
return "Utilisateur connecté";
}
}
@Injectable()
class UserService {
private authService: AuthService;
private dbService: DatabaseService; // Exemple d'injection via une propriété en utilisant un décorateur personnalisé ou une fonctionnalité de framework
constructor(@Inject(AuthService) authService: AuthService,
@Inject(DatabaseService) dbService: DatabaseService) {
this.authService = authService;
this.dbService = dbService;
}
getUserProfile() {
this.authService.login();
this.dbService.connect();
console.log("UserService : Récupération du profil utilisateur...");
return { id: 1, name: "Utilisateur Global" };
}
}
// Résoudre le service principal
console.log("--- Résolution de UserService ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());
console.log("\n--- Résolution de AuthService (devrait être en cache) ---");
const authService = Container.resolve(AuthService);
authService.login();
Cet exemple élaboré montre comment les décorateurs @Injectable
et @Inject
, combinés à reflect-metadata
, permettent à un Container
personnalisé de résoudre et de fournir automatiquement les dépendances. Les métadonnées design:paramtypes
automatiquement émises par TypeScript (lorsque emitDecoratorMetadata
est à true) sont cruciales ici.
2. Programmation Orientée Aspect (POA)
La POA se concentre sur la modularisation des préoccupations transversales (par ex., la journalisation, la sécurité, les transactions) qui traversent plusieurs classes et modules. Les décorateurs sont un excellent moyen d'implémenter les concepts de la POA en TypeScript.
Exemple : Journalisation avec un Décorateur de Méthode
En revenant au décorateur LogCall
, c'est un exemple parfait de POA. Il ajoute un comportement de journalisation à n'importe quelle méthode sans modifier le code original de la méthode. Cela sépare le "quoi faire" (logique métier) du "comment le faire" (journalisation, surveillance des performances, etc.).
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG POA] Entrée dans la méthode : ${String(propertyKey)} avec les arguments :`, args);
try {
const result = originalMethod.apply(this, args);
console.log(`[LOG POA] Sortie de la méthode : ${String(propertyKey)} avec le résultat :`, result);
return result;
} catch (error: any) {
console.error(`[LOG POA] Erreur dans la méthode ${String(propertyKey)} :`, error.message);
throw error;
}
};
return descriptor;
}
class PaymentProcessor {
@LogMethod
processPayment(amount: number, currency: string) {
if (amount <= 0) {
throw new Error("Le montant du paiement doit être positif.");
}
console.log(`Traitement du paiement de ${amount} ${currency}...`);
return `Paiement de ${amount} ${currency} traité avec succès.`;
}
@LogMethod
refundPayment(transactionId: string) {
console.log(`Remboursement du paiement pour l'ID de transaction : ${transactionId}...`);
return `Remboursement initié pour ${transactionId}.`;
}
}
const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
processor.processPayment(-50, "EUR");
} catch (error: any) {
console.error("Erreur interceptée :", error.message);
}
Cette approche maintient la classe PaymentProcessor
concentrée uniquement sur la logique de paiement, tandis que le décorateur LogMethod
gère la préoccupation transversale de la journalisation.
3. Validation et Transformation
Les décorateurs sont incroyablement utiles pour définir des règles de validation directement sur les propriétés ou pour transformer des données lors de la sérialisation/désérialisation.
Exemple : Validation de Données avec des Décorateurs de Propriété
L'exemple @Required
précédent l'a déjà démontré. Voici un autre exemple avec une validation de plage numérique.
interface FieldValidationRule {
property: string | symbol;
validator: (value: any) => boolean;
message: string;
}
const fieldValidationRules = new Map<Function, FieldValidationRule[]>();
function addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {
const rules = fieldValidationRules.get(target.constructor) || [];
rules.push({ property: propertyKey, validator, message });
fieldValidationRules.set(target.constructor, rules);
}
function IsPositive(target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} doit être un nombre positif.`);
}
function MaxLength(maxLength: number) {
return function (target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} ne doit pas dépasser ${maxLength} caractères.`);
};
}
class Product {
@MaxLength(50)
name: string;
@IsPositive
price: number;
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
static validate(instance: any): string[] {
const errors: string[] = [];
const rules = fieldValidationRules.get(instance.constructor) || [];
for (const rule of rules) {
if (!rule.validator(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
}
const product1 = new Product("Laptop", 1200);
console.log("Erreurs produit 1 :", Product.validate(product1)); // []
const product2 = new Product("Nom de produit très long qui dépasse la limite de cinquante caractères à des fins de test", 50);
console.log("Erreurs produit 2 :", Product.validate(product2)); // ["name ne doit pas dépasser 50 caractères."]
const product3 = new Product("Book", -10);
console.log("Erreurs produit 3 :", Product.validate(product3)); // ["price doit être un nombre positif."]
Cette configuration vous permet de définir de manière déclarative des règles de validation sur les propriétés de votre modèle, rendant vos modèles de données auto-descriptifs en termes de contraintes.
Meilleures Pratiques et Considérations
Bien que les décorateurs soient puissants, ils doivent être utilisés judicieusement. Une mauvaise utilisation peut conduire à un code plus difficile à déboguer ou à comprendre.
Quand Utiliser les Décorateurs (et Quand Ne Pas le Faire)
- Utilisez-les pour :
- Les préoccupations transversales : Journalisation, mise en cache, autorisation, gestion des transactions.
- La déclaration de métadonnées : Définir des schémas pour les ORM, des règles de validation, la configuration de l'ID.
- L'intégration de frameworks : Lors de la création ou de l'utilisation de frameworks qui exploitent les métadonnées.
- La réduction du code répétitif : Abstraire les patrons de code répétitifs.
- Évitez-les pour :
- Des appels de fonction simples : Si un simple appel de fonction peut atteindre le même résultat clairement, préférez cette solution.
- La logique métier : Les décorateurs devraient augmenter, et non définir, la logique métier principale.
- La sur-complexification : Si l'utilisation d'un décorateur rend le code moins lisible ou plus difficile à tester, reconsidérez votre approche.
Implications sur les Performances
Les décorateurs s'exécutent au moment de la compilation (ou au moment de la définition dans l'environnement d'exécution JavaScript s'ils sont transpilés). La transformation ou la collecte de métadonnées se produit lorsque la classe/méthode est définie, pas à chaque appel. Par conséquent, l'impact sur les performances d'exécution de *l'application* des décorateurs est minime. Cependant, la *logique à l'intérieur* de vos décorateurs peut avoir un impact sur les performances, surtout s'ils effectuent des opérations coûteuses à chaque appel de méthode (par exemple, des calculs complexes dans un décorateur de méthode).
Maintenabilité et Lisibilité
Les décorateurs, lorsqu'ils sont utilisés correctement, peuvent améliorer considérablement la lisibilité en déplaçant le code répétitif hors de la logique principale. Cependant, s'ils effectuent des transformations complexes et cachées, le débogage peut devenir difficile. Assurez-vous que vos décorateurs sont bien documentés et que leur comportement est prévisible.
Statut Expérimental et Avenir des Décorateurs
Il est important de rappeler que les décorateurs TypeScript sont basés sur une proposition TC39 de stade 3. Cela signifie que la spécification est largement stable mais pourrait encore subir des modifications mineures avant de faire partie de la norme officielle ECMAScript. Des frameworks comme Angular les ont adoptés, pariant sur leur standardisation éventuelle. Cela implique un certain niveau de risque, bien que, compte tenu de leur adoption généralisée, des changements majeurs et disruptifs soient peu probables.
La proposition TC39 a évolué. L'implémentation actuelle de TypeScript est basée sur une ancienne version de la proposition. Il y a une distinction entre les "Décorateurs Hérités" (Legacy Decorators) et les "Décorateurs Standards" (Standard Decorators). Lorsque la norme officielle sera finalisée, TypeScript mettra probablement à jour son implémentation. Pour la plupart des développeurs utilisant des frameworks, cette transition sera gérée par le framework lui-même. Pour les auteurs de bibliothèques, comprendre les différences subtiles entre les décorateurs hérités et les futurs décorateurs standards pourrait devenir nécessaire.
L'Option de Compilateur emitDecoratorMetadata
Cette option, lorsqu'elle est définie sur true
dans tsconfig.json
, indique au compilateur TypeScript d'émettre certaines métadonnées de type de conception dans le JavaScript compilé. Ces métadonnées incluent le type des paramètres du constructeur (design:paramtypes
), le type de retour des méthodes (design:returntype
), et le type des propriétés (design:type
).
Ces métadonnées émises ne font pas partie de l'environnement d'exécution standard de JavaScript. Elles sont généralement consommées par le polyfill reflect-metadata
, qui les rend ensuite accessibles via les fonctions Reflect.getMetadata()
. C'est absolument essentiel pour les patrons avancés comme l'Injection de Dépendances, où un conteneur a besoin de connaître les types de dépendances qu'une classe requiert sans configuration explicite.
Patrons Avancés avec les Décorateurs
Les décorateurs peuvent être combinés et étendus pour construire des patrons encore plus sophistiqués.
1. Décorer des Décorateurs (Décorateurs d'Ordre Supérieur)
Vous pouvez créer des décorateurs qui modifient ou composent d'autres décorateurs. C'est moins courant mais démontre la nature fonctionnelle des décorateurs.
// Un décorateur qui garantit qu'une méthode est journalisée et nécessite également les rôles d'administrateur
function AdminAndLoggedMethod() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Appliquer d'abord Authorization (interne)
Authorization(["admin"])(target, propertyKey, descriptor);
// Appliquer ensuite LogCall (externe)
LogCall(target, propertyKey, descriptor);
return descriptor; // Retourne le descripteur modifié
};
}
class AdminPanel {
@AdminAndLoggedMethod()
deleteUserAccount(userId: string) {
console.log(`Suppression du compte utilisateur : ${userId}`);
return `Utilisateur ${userId} supprimé.`;
}
}
const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Sortie Attendue (en supposant le rôle d'administrateur) :
[AUTH] Accès accordé pour deleteUserAccount
[LOG] Appel de deleteUserAccount avec les arguments : [ 'user007' ]
Suppression du compte utilisateur : user007
[LOG] La méthode deleteUserAccount a retourné : Utilisateur user007 supprimé.
*/
Ici, AdminAndLoggedMethod
est une fabrique qui retourne un décorateur, et à l'intérieur de ce décorateur, elle applique deux autres décorateurs. Ce patron peut encapsuler des compositions de décorateurs complexes.
2. Utiliser les Décorateurs pour les Mixins
Bien que TypeScript offre d'autres moyens d'implémenter les mixins, les décorateurs peuvent être utilisés pour injecter des capacités dans les classes de manière déclarative.
function ApplyMixins(constructors: Function[]) {
return function (derivedConstructor: Function) {
constructors.forEach(baseConstructor => {
Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {
Object.defineProperty(
derivedConstructor.prototype,
name,
Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)
);
});
});
};
}
class Disposable {
isDisposed: boolean = false;
dispose() {
this.isDisposed = true;
console.log("Objet libéré.");
}
}
class Loggable {
log(message: string) {
console.log(`[Loggable] ${message}`);
}
}
@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
// Ces propriétés/méthodes sont injectées par le décorateur
isDisposed!: boolean;
dispose!: () => void;
log!: (message: string) => void;
constructor(public name: string) {
this.log(`Ressource ${this.name} créée.`);
}
cleanUp() {
this.dispose();
this.log(`Ressource ${this.name} nettoyée.`);
}
}
const resource = new MyResource("NetworkConnection");
console.log(`Est libéré : ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Est libéré : ${resource.isDisposed}`);
Ce décorateur @ApplyMixins
copie dynamiquement les méthodes et propriétés des constructeurs de base vers le prototype de la classe dérivée, "mixant" efficacement les fonctionnalités.
Conclusion : Renforcer le Développement TypeScript Moderne
Les décorateurs TypeScript sont une fonctionnalité puissante et expressive qui permet un nouveau paradigme de programmation pilotée par les métadonnées et orientée aspect. Ils permettent aux développeurs d'améliorer, de modifier et d'ajouter des comportements déclaratifs aux classes, méthodes, propriétés, accesseurs et paramètres sans altérer leur logique de base. Cette séparation des préoccupations conduit à un code plus propre, plus maintenable et hautement réutilisable.
De la simplification de l'injection de dépendances et de la mise en œuvre de systèmes de validation robustes à l'ajout de préoccupations transversales comme la journalisation et la surveillance des performances, les décorateurs offrent une solution élégante à de nombreux défis de développement courants. Bien que leur statut expérimental justifie une certaine prudence, leur adoption généralisée dans les principaux frameworks témoigne de leur valeur pratique et de leur pertinence future.
En maîtrisant les décorateurs TypeScript, vous gagnez un outil significatif dans votre arsenal, vous permettant de construire des applications plus robustes, évolutives et intelligentes. Adoptez-les de manière responsable, comprenez leurs mécanismes et débloquez un nouveau niveau de puissance déclarative dans vos projets TypeScript.