Design patterns en TypeScript - 04 - Observer et EventEmitter : reagir aux changements

Le pattern Observer en TypeScript. EventEmitter natif de Node, events custom et le découplage par les événements.

04 - Observer et EventEmitter : reagir aux changements

Ce que tu vas apprendre

  • Le pattern Observer et sa version Node.js (EventEmitter)
  • Comment typer les events proprement en TypeScript
  • Les pièges : memory leaks et erreurs silencieuses

Prerequisites

Avoir lu l'introduction de la serie.


Le problème

Une commande est créée. Il faut envoyer un email de confirmation, mettre à jour le stock, notifier l'équipe sur Slack, et enregistrer une ligne d'analytics. Le reflexe du junior :

typescriptasync function createOrder(data: OrderData): Promise<Order> {
  const order = await orderRepo.save(data);
  await sendConfirmationEmail(order);     // mail
  await updateInventory(order);           // stock
  await notifySlack(order);               // slack
  await trackAnalytics("order_created", order); // analytics
  return order;
}

Quatre dépendances directes. createOrder connaît le système mail, le stock, Slack et l'analytics. Ajouter un cinquieme effet de bord ? Tu modifies createOrder. Le service Slack est en panne ? La commande echoue. Tester createOrder demande de mocker quatre services.

La solution : emettre un événement

Au lieu d'appeler directement chaque service, createOrder emet un événement. Ceux qui veulent reagir s'abonnent.

typescriptimport { EventEmitter } from "events";

const bus = new EventEmitter();

// Chaque service s'abonne independamment
bus.on("order:created", (order: Order) => sendConfirmationEmail(order));
bus.on("order:created", (order: Order) => updateInventory(order));
bus.on("order:created", (order: Order) => notifySlack(order));
bus.on("order:created", (order: Order) => trackAnalytics("order_created", order));

// createOrder ne connait plus personne
async function createOrder(data: OrderData): Promise<Order> {
  const order = await orderRepo.save(data);
  bus.emit("order:created", order);
  return order;
}

createOrder ne sait pas qui écoûte. Ajouter un cinquieme listener ? Un bus.on de plus, zero modification de createOrder. Slack est en panne ? Le listener Slack echoue, les autres continuent. Le découplage est total.

Typer les events (parce que `any`, non merci)

Le problème d'EventEmitter natif : tout est any. Tu peux emettre "order:craeted" (avec une typo) et personne ne te previent. La solution :

typescripttype AppEvents = {
  "order:created": [Order];
  "order:cancelled": [Order, string]; // order + raison
  "user:registered": [User];
  "payment:failed": [Order, Error];
};

class TypedEmitter extends EventEmitter {
  emit<K extends keyof AppEvents>(event: K, ...args: AppEvents[K]): boolean {
    return super.emit(event, ...args);
  }

  on<K extends keyof AppEvents>(event: K, listener: (...args: AppEvents[K]) => void): this {
    return super.on(event, listener as any);
  }

  off<K extends keyof AppEvents>(event: K, listener: (...args: AppEvents[K]) => void): this {
    return super.off(event, listener as any);
  }
}

const bus = new TypedEmitter();

// TypeScript connait le type du parametre
bus.on("order:created", (order) => {
  // order est type Order, pas any
  console.log(order.id);
});

// Erreur de compilation : "order:craeted" n'existe pas
// bus.emit("order:craeted", someOrder);

Ce wrapper de 20 lignes t'évité une categorie entière de bugs. Tu peux aussi utiliser la lib eventemitter3 qui offre des generics natifs, ou mitt qui est encore plus legere.

Observer classique vs EventEmitter

Le pattern Observer du GoF definit un Subject qui maintient une liste d'Observers. En TypeScript, l'EventEmitter fait exactement ca, mais avec des noms d'events au lieu de classes Observer. La différence est surtout syntaxique.

Si tu veux l'approche classique sans EventEmitter :

typescripttype Listener<T> = (data: T) => void;

class Observable<T> {
  private listeners: Listener<T>[] = [];

  subscribe(listener: Listener<T>): () => void {
    this.listeners.push(listener);
    // Retourne une fonction de desabonnement
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }

  notify(data: T): void {
    for (const listener of this.listeners) {
      listener(data);
    }
  }
}

// Usage
const orderEvents = new Observable<Order>();
const unsub = orderEvents.subscribe((order) => console.log("New order:", order.id));
orderEvents.notify(newOrder);
unsub(); // se desabonner

L'avantage de cette version : le subscribe retourne une fonction de cleanup. Tu n'as pas besoin de garder une référencé au listener pour te desabonner. C'est un pattern qu'on retrouve dans React (le cleanup des useEffect) et dans RxJS.

L'auto-tagging RSS de paltemps.fr

Sur paltemps.fr, quand un nouveau flux RSS est ajoute, le système analyse le contenu et attribue des tags automatiquement. Ca ressemble a un Observer : l'ajout d'un article déclenché une reaction (le tagging) sans que le module RSS ne connaisse le module de tagging.

typescriptbus.on("rss:article_added", async (article: RssArticle) => {
  const tags = await analyzeContent(article.title, article.summary);
  await articleRepo.updateTags(article.id, tags);
});

Un listener, une responsabilité. Si demain on veut aussi générer un résumé IA de chaque article, on ajoute un listener. Le module RSS reste inchange.

Les pièges

Erreurs silencieuses. Si un listener throw, par défaut EventEmitter emet un event "error" non attrape et crash le process. Ou pire, si tu catches l'erreur dans le listener, l'emetteur ne sait pas que quelque chose a echoue. Si l'email de confirmation DOIT partir (requirement métier), ne le mets pas dans un event. Appelle-le directement.

typescript// Si le mail DOIT partir, pas d'event :
async function createOrder(data: OrderData): Promise<Order> {
  const order = await orderRepo.save(data);
  await sendConfirmationEmail(order); // obligatoire, appel direct
  bus.emit("order:created", order);   // le reste est optionnel
  return order;
}

Memory leaks. Chaque bus.on(...) ajoute un listener. Si tu t'abonnes dans une boucle ou dans un handler de requête sans te desabonner, tu accumules des listeners. Node affiche un warning a 11 listeners par event. Écoûte ce warning.

typescript// MAUVAIS : un nouveau listener a chaque requete
app.get("/orders", (req, res) => {
  bus.on("order:created", (order) => { /* ... */ }); // leak !
});

Ordre d'exécution. Les listeners sont appeles dans l'ordre d'enregistrement, de facon synchrone par défaut. Si un listener est async, l'emetteur n'attend pas la fin. bus.emit retourne immédiatement. Si tu as besoin d'attendre, c'est un signe que tu ne devrais pas utiliser un event.


Résumé

  • Observer/EventEmitter découplé l'emetteur des recepteurs
  • Type tes events pour éviter les typos et les any
  • Utilise des events pour les effets de bord optionnels, pas pour les opérations obligatoires
  • Attention aux memory leaks (listeners jamais retires)

Article précédent : 03 - Strategy

Article suivant : 05 - Adapter : faire parler deux interfaces incompatibles

Sources

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