07 - Decorator : ajouter des comportements sans modifier le code
Ce que tu vas apprendre
- Le Decorator comme higher-order function (l'approche TypeScript)
- Des decorateurs concrets : logging, cache, retry, timeout
- Le lien entre Decorator et middleware (Express, Elysia)
Prerequisites
Avoir lu l'introduction de la serie. Des bases en higher-order functions (fonctions qui retournent des fonctions).
Le problème
Tu as une fonction fetchUser qui marche. Maintenant tu veux ajouter du logging. Tu modifies fetchUser. Ensuite tu veux du cache. Tu re-modifies fetchUser. Puis du retry en cas d'erreur réseau. fetchUser fait maintenant 80 lignes dont 15 de logique métier et 65 de plomberie.
typescript// fetchUser est devenu un monstre
async function fetchUser(id: string): Promise<User> {
console.log(`[fetchUser] called with ${id}`); // logging
const cached = cache.get(`user:${id}`); // cache
if (cached) { console.log(`[fetchUser] cache hit`); return cached; }
let attempts = 0; // retry
while (attempts < 3) {
try {
const user = await db.query("SELECT ...", [id]); // la vraie logique
cache.set(`user:${id}`, user, 60); // cache write
console.log(`[fetchUser] returned`, user); // logging
return user;
} catch (e) {
attempts++;
if (attempts >= 3) throw e;
await new Promise(r => setTimeout(r, 1000 * attempts)); // backoff
}
}
throw new Error("unreachable");
}
La logique métier (une requête SQL) est noyee dans du code technique. Si tu veux retirer le cache, bonne chance pour demeler tout ca.
La solution : des decorateurs (higher-order functions)
Une higher-order function qui prend une fonction et retourne une nouvelle fonction avec un comportement ajoute :
typescriptfunction withLogging<Args extends any[], R>(
fn: (...args: Args) => Promise<R>,
name: string,
): (...args: Args) => Promise<R> {
return async (...args: Args): Promise<R> => {
console.log(`[${name}] called with`, args);
const result = await fn(...args);
console.log(`[${name}] returned`, result);
return result;
};
}
Usage :
typescriptconst fetchUserOriginal = async (id: string): Promise<User> => {
return db.query("SELECT * FROM users WHERE id = $1", [id]);
};
const fetchUser = withLogging(fetchUserOriginal, "fetchUser");
fetchUser loggue chaque appel et chaque retour. La fonction originale n'a pas ete modifiee. Tu peux retirer le logging en enlevant le wrapper.
Des decorateurs utiles en vrai
Voici quatre decorateurs que j'utilise régulièrement :
typescript// Cache avec TTL
function withCache<Args extends any[], R>(
fn: (...args: Args) => Promise<R>,
keyFn: (...args: Args) => string,
ttlSeconds: number,
): (...args: Args) => Promise<R> {
const cache = new Map<string, { value: R; expires: number }>();
return async (...args: Args): Promise<R> => {
const key = keyFn(...args);
const cached = cache.get(key);
if (cached && cached.expires > Date.now()) return cached.value;
const result = await fn(...args);
cache.set(key, { value: result, expires: Date.now() + ttlSeconds * 1000 });
return result;
};
}
// Retry avec backoff exponentiel
function withRetry<Args extends any[], R>(
fn: (...args: Args) => Promise<R>,
maxAttempts: number = 3,
): (...args: Args) => Promise<R> {
return async (...args: Args): Promise<R> => {
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(...args);
} catch (e) {
lastError = e as Error;
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
}
throw lastError!;
};
}
// Timeout (clearTimeout evite l'accumulation de timers en memoire)
function withTimeout<Args extends any[], R>(
fn: (...args: Args) => Promise<R>,
ms: number,
): (...args: Args) => Promise<R> {
return async (...args: Args): Promise<R> => {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
});
try {
return await Promise.race([fn(...args), timeout]);
} finally {
clearTimeout(timer!);
}
};
}
// Auth check
function withAuth<Args extends any[], R>(
fn: (...args: Args) => Promise<R>,
getToken: () => string | null,
): (...args: Args) => Promise<R> {
return async (...args: Args): Promise<R> => {
const token = getToken();
if (!token) throw new Error("Not authenticated");
return fn(...args);
};
}
Composer les decorateurs
La force du pattern : tu empiles les comportements comme des couches.
typescriptconst fetchUser = withLogging(
withCache(
withRetry(
withTimeout(fetchUserOriginal, 5000),
3
),
(id) => `user:${id}`,
60
),
"fetchUser"
);
L'ordre compte. Ici : logging (externe) -> cache -> retry -> timeout -> fonction originale. Si le cache a la réponse, pas de retry ni de timeout. Si le timeout se déclenché, le retry reessaie. Le logging capture tout.
C'est le meme principe que pipe ou compose en programmation fonctionnelle. Si tu trouves l'imbrication illisible, tu peux utiliser un helper :
typescriptfunction compose<T>(...fns: Array<(fn: T) => T>): (fn: T) => T {
return (fn: T) => fns.reduceRight((acc, f) => f(acc), fn);
}
// Plus lisible (les decorateurs s'appliquent de bas en haut)
const fetchUser = compose(
(fn) => withLogging(fn, "fetchUser"),
(fn) => withCache(fn, (id) => `user:${id}`, 60),
(fn) => withRetry(fn, 3),
(fn) => withTimeout(fn, 5000),
)(fetchUserOriginal);
Middleware = Decorator
Si tu utilises Express ou Elysia, tu connais deja le pattern Decorator sans le savoir. Un middleware est un decorateur applique a un handler HTTP.
typescript// Express middleware = decorateur de route
app.get("/orders",
authMiddleware, // verifie le token
rateLimiter(100), // limite 100 req/min
cacheMiddleware(30), // cache 30 secondes
orderController.list // le vrai handler
);
Chaque middleware ajoute un comportement (auth, rate limit, cache) sans modifier le handler. C'est exactement le pattern Decorator. Sur paltemps.fr, avec Elysia, les plugins (auth, CORS, validation) fonctionnent de la meme facon.
TC39 Decorators vs higher-order functions
TypeScript a un support experimental des decorators avec la syntaxe @decorator :
typescriptclass UserService {
@Log
@Cache(60)
@Retry(3)
async fetchUser(id: string): Promise<User> {
return db.query("SELECT ...", [id]);
}
}
C'est elegant syntaxiquement. Mais ca ne marche qu'avec des classes. Les decorators TC39 (stage 3) sont différents des "legacy decorators" de TypeScript. L'ecosysteme est encore en transition. Angular et NestJS les utilisent beaucoup, mais pour du TypeScript vanilla, les higher-order functions sont plus simples et plus portables.
Mon avis : si tu utilises NestJS, utilise les @decorators. Sinon, les higher-order functions sont suffisantes et ne dependent d'aucune config speciale dans ton tsconfig.json.
Quand ne PAS utiliser un decorateur
Si le comportement est spécifique a une seule fonction et ne sera jamais réutilisé, mets-le directement dans la fonction. Un decorateur pour un seul usage, c'est de l'over-engineering.
Autre piège : trop de couches. Si ta fonction passe par 6 decorateurs, le debugging devient penible. Chaque couche ajoute un niveau dans la stack trace. Limite-toi a 3-4 decorateurs max par fonction, et teste chaque decorateur indépendamment.
Résumé
- Un Decorator est une higher-order function qui ajoute un comportement sans modifier l'original
- Les cas d'usage classiques : logging, cache, retry, timeout, auth
- Les middleware Express/Elysia sont des decorateurs
- Les decorateurs TC39 (
@decorator) sont une alternative syntaxique pour les classes
Article précédent : 06 - Builder
Article suivant : 08 - Singleton, Service Locator et Dependency Injection