Cours JavaScript et TypeScript · Module 10: TypeScript en pratique

Système d'événements type-safe en TypeScript

9 min de lecture
Dans cette leçon5

À Jurassic Park, chaque système doit réagir immédiatement aux événements : alarme d'évasion des raptors, changement d'état d'une clôture, nouvelle position d'un dinosaure. Un tel système d'événements (event system) exige de la précision, car tu ne peux pas envoyer des données de température à un handler qui attend des coordonnées GPS. Un émetteur ordinaire accepte n'importe quelle chaîne et n'importe quelles données, donc une faute de frappe dans le nom d'un événement fait disparaître une alarme sans le moindre bruit. TypeScript permet de créer un système d'events entièrement typé, dans lequel le compilateur veille à l'exactitude à ta place.

EventMap - la carte des types d'événements

La fondation d'un event system type-safe est l'EventMap : une interface qui associe les noms d'événements aux types de leurs données (payload). Les noms du style dinosaur:escaped sont de simples clés de type chaîne, et le deux-points les range par groupes :

1// On définit la carte des événements du parc
2interface ParkEventMap {
3  'dinosaur:escaped': { species: string; paddock: string; dangerLevel: number };
4  'fence:status': { zone: string; voltage: number; active: boolean };
5  'visitor:enter': { visitorId: string; gate: string; timestamp: Date };
6  'visitor:exit': { visitorId: string; gate: string; timestamp: Date };
7  'alert:weather': { type: 'storm' | 'heat' | 'flood'; severity: 1 | 2 | 3 };
8}
9
10// TypeScript connaît la forme exacte des données de chaque événement
11// 'dinosaur:escaped' => { species: string; paddock: string; dangerLevel: number }
12// 'fence:status' => { zone: string; voltage: number; active: boolean }

L'interface elle-même ne génère aucun code. C'est un contrat dont toutes les méthodes de l'émetteur vont se servir.

Un EventEmitter générique avec TypeScript

Passons à l'émetteur générique. Le paramètre T est la carte des événements, et chaque méthode a son propre paramètre K extends keyof T, c'est-à-dire le nom d'un seul événement, donc T[K] désigne exactement le payload de cet événement :

1// EventEmitter générique - T est la carte des events
2class TypedEventEmitter<T extends Record<string, any>> {
3  private listeners: {
4    [K in keyof T]?: Array<(data: T[K]) => void>;
5  } = {};
6
7  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
8    if (!this.listeners[event]) {
9      this.listeners[event] = [];
10    }
11    this.listeners[event]!.push(handler);
12  }
13
14  off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
15    const handlers = this.listeners[event];
16    if (handlers) {
17      const index = handlers.indexOf(handler);
18      if (index > -1) handlers.splice(index, 1);
19    }
20  }
21
22  emit<K extends keyof T>(event: K, data: T[K]): void {
23    const handlers = this.listeners[event];
24    if (handlers) {
25      [...handlers].forEach(handler => handler(data)); // copie : once() supprime un handler pendant la boucle
26    }
27  }
28
29  once<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
30    const wrapper = (data: T[K]) => {
31      handler(data);
32      this.off(event, wrapper);
33    };
34    this.on(event, wrapper);
35  }
36}

emit parcourt une copie du tableau des handlers, car once supprime son wrapper pendant la boucle, et sur l'original, le handler suivant serait sauté. La classe ne connaît aucun événement concret, elle lit tout dans la carte. Le point d'exclamation de this.listeners[event]! est une assertion non-null : tu affirmes au compilateur que le tableau existe forcément, puisque tu l'as créé une ligne plus haut.

Branchons la carte du parc. Le paramètre data du handler n'a pas besoin d'annotation, car TypeScript déduit son type du nom de l'événement :

1// Utilisation avec ParkEventMap
2const parkEvents = new TypedEventEmitter<ParkEventMap>();
3
4// TypeScript connaît le type du paramètre du handler - l'autocomplétion fonctionne !
5parkEvents.on('dinosaur:escaped', (data) => {
6  // data est de type { species: string; paddock: string; dangerLevel: number }
7  console.log(`ALARME ! ${data.species} s'est échappé de ${data.paddock} !`);
8  console.log(`Niveau de menace : ${data.dangerLevel}`);
9});
10
11parkEvents.on('fence:status', (data) => {
12  // data est de type { zone: string; voltage: number; active: boolean }
13  if (!data.active) {
14    console.log(`Clôture de la zone ${data.zone} désactivée !`);
15  }
16});

Dans le premier handler, l'autocomplétion proposera species, paddock et dangerLevel, et dans le second, des champs complètement différents.

L'appel à emit prend le nom de l'événement et un objet qui lui correspond. Regarde ce que le compilateur fait des appels erronés :

1// Émissions correctes
2parkEvents.emit('dinosaur:escaped', {
3  species: 'Velociraptor',
4  paddock: 'B-7',
5  dangerLevel: 5
6});
7
8// Erreur de compilation ! Il manque le champ 'dangerLevel'
9// parkEvents.emit('dinosaur:escaped', { species: 'T-Rex', paddock: 'A-1' });
10
11// Erreur de compilation ! Événement inconnu
12// parkEvents.emit('dinosaur:sleeping', { species: 'Stegosaurus' });

Un champ manquant et un nom d'événement inconnu sont des erreurs de compilation, et non des pannes silencieuses dans le parc en pleine activité. C'est le principal avantage sur un émetteur non typé.

Pattern : un addEventListener fortement typé

Dans les définitions de types du DOM fournies par TypeScript, addEventListener est typé précisément grâce à des cartes d'événements comme HTMLElementEventMap. Construisons notre propre version de ce pattern, en commençant par la carte et la classe de base :

1// Carte des events pour un composant UI
2interface UIEventMap {
3  click: { x: number; y: number; target: string };
4  hover: { x: number; y: number; element: string };
5  keypress: { key: string; code: number; ctrl: boolean; shift: boolean };
6  resize: { width: number; height: number };
7  scroll: { scrollTop: number; scrollLeft: number };
8}
9
10// Classe de composant avec des events typés
11class UIComponent<Events extends Record<string, any> = UIEventMap> {
12  private eventHandlers: Map<keyof Events, Set<Function>> = new Map();
13
14  addEventListener<K extends keyof Events>(
15    type: K,
16    listener: (event: Events[K]) => void
17  ): void {
18    if (!this.eventHandlers.has(type)) {
19      this.eventHandlers.set(type, new Set());
20    }
21    this.eventHandlers.get(type)!.add(listener);
22  }
23
24  removeEventListener<K extends keyof Events>(
25    type: K,
26    listener: (event: Events[K]) => void
27  ): void {
28    this.eventHandlers.get(type)?.delete(listener);
29  }
30
31  protected dispatchEvent<K extends keyof Events>(
32    type: K,
33    event: Events[K]
34  ): void {
35    this.eventHandlers.get(type)?.forEach(handler => {
36      (handler as (event: Events[K]) => void)(event);
37    });
38  }
39}

Le paramètre par défaut = UIEventMap fait qu'un composant ordinaire connaît d'emblée les événements de base, et dispatchEvent est protected, donc seul le composant lui-même émet des événements.

La carte étendue avec extends ajoute les événements propres à la carte du parc, et la classe dérivée la transmet comme paramètre de type :

1// Composant étendu avec ses propres events
2interface MapComponentEvents extends UIEventMap {
3  'marker:click': { markerId: string; lat: number; lng: number };
4  'zone:select': { zoneId: string; name: string };
5}
6
7class ParkMapComponent extends UIComponent<MapComponentEvents> {
8  selectZone(zoneId: string, name: string): void {
9    this.dispatchEvent('zone:select', { zoneId, name });
10  }
11
12  clickMarker(markerId: string, lat: number, lng: number): void {
13    this.dispatchEvent('marker:click', { markerId, lat, lng });
14  }
15}
16
17// Utilisation
18const parkMap = new ParkMapComponent();
19
20parkMap.addEventListener('marker:click', (event) => {
21  // event: { markerId: string; lat: number; lng: number }
22  console.log(`Marqueur ${event.markerId} cliqué en ${event.lat}, ${event.lng}`);
23});
24
25parkMap.addEventListener('click', (event) => {
26  // event: { x: number; y: number; target: string }
27  console.log(`Clic en ${event.x}, ${event.y}`);
28});

parkMap connaît à la fois ses propres événements (marker:click) et ceux dont elle hérite (click), et chaque handler reçoit le bon type.

Le pattern middleware avec des events typés

Un pattern avancé est le middleware, qui traite l'événement avant qu'il n'atteigne le handler, comme un sas à l'entrée d'un enclos. reduceRight assemble la chaîne en partant de la fin, donc les middlewares s'exécutent dans l'ordre de leur enregistrement :

1// Type du middleware - il traite l'event et décide s'il le laisse passer
2type EventMiddleware<T> = (
3  data: T,
4  next: (data: T) => void
5) => void;
6
7class MiddlewareEventEmitter<T extends Record<string, any>> {
8  private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
9  private middleware: { [K in keyof T]?: Array<EventMiddleware<T[K]>> } = {};
10
11  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
12    if (!this.listeners[event]) this.listeners[event] = [];
13    this.listeners[event]!.push(handler);
14  }
15
16  use<K extends keyof T>(event: K, mw: EventMiddleware<T[K]>): void {
17    if (!this.middleware[event]) this.middleware[event] = [];
18    this.middleware[event]!.push(mw);
19  }
20
21  emit<K extends keyof T>(event: K, data: T[K]): void {
22    const mws = this.middleware[event] || [];
23    const handlers = this.listeners[event] || [];
24
25    // On construit la chaîne de middlewares
26    const chain = mws.reduceRight(
27      (next: (data: T[K]) => void, mw: EventMiddleware<T[K]>) => {
28        return (d: T[K]) => mw(d, next);
29      },
30      (finalData: T[K]) => {
31        handlers.forEach(h => h(finalData));
32      }
33    );
34
35    chain(data);
36  }
37}
38
39// Exemple : journalisation et validation des events
40const securitySystem = new MiddlewareEventEmitter<ParkEventMap>();
41
42// Middleware de journalisation
43securitySystem.use('dinosaur:escaped', (data, next) => {
44  console.log(`[LOG] Événement d'évasion : ${data.species}`);
45  next(data);
46});
47
48// Middleware qui élève le niveau de menace
49securitySystem.use('dinosaur:escaped', (data, next) => {
50  if (data.species === 'T-Rex' && data.dangerLevel < 5) {
51    next({ ...data, dangerLevel: 5 }); // Escalade !
52  } else {
53    next(data);
54  }
55});
56
57securitySystem.on('dinosaur:escaped', (data) => {
58  console.log(`Alerte finale : ${data.species}, menace : ${data.dangerLevel}`);
59});
60
61securitySystem.emit('dinosaur:escaped', {
62  species: 'T-Rex',
63  paddock: 'A-1',
64  dangerLevel: 3 // Sera élevé à 5 par le middleware
65});

Le deuxième middleware élève le niveau de menace du T-Rex à 5 avant que le handler ne le voie, et l'objet d'origine reste intact grâce au spread.

Extraire les types d'événements (utility types pour les events)

Pour finir, des types utilitaires. EventsWithField combine un type mappé et un type conditionnel : l'écriture A extends B ? X : Y fonctionne comme un if dans le monde des types, c'est-à-dire qu'elle vérifie le type d'entrée, évalue la condition extends et renvoie X ou Y :

1// Utility types pour travailler avec les cartes d'events
2type EventNames<T> = keyof T & string;
3type EventPayload<T, K extends keyof T> = T[K];
4type EventHandler<T, K extends keyof T> = (data: T[K]) => void;
5
6// Extraction du handler d'un event précis
7type ParkEscapeHandler = EventHandler<ParkEventMap, 'dinosaur:escaped'>;
8// (data: { species: string; paddock: string; dangerLevel: number }) => void
9
10// Type union de tous les payloads
11type AnyParkPayload = ParkEventMap[keyof ParkEventMap];
12
13// Filtrage des events selon le type de leur payload
14type EventsWithField<T, Field extends string> = {
15  [K in keyof T]: Field extends keyof T[K] ? K : never;
16}[keyof T];
17
18type EventsWithVisitorId = EventsWithField<ParkEventMap, 'visitorId'>;
19// 'visitor:enter' | 'visitor:exit'

Tu peux combiner un type conditionnel avec infer pour extraire une partie d'un type, par exemple type Return<T> = T extends (...args: any) => infer R ? R : never renvoie le type de retour d'une fonction.

Le système d'événements type-safe est un pattern qui fait ses preuves partout : des systèmes de surveillance de Jurassic Park au frontend React, en passant par les backends Node.js et les microservices. Un TypedEventEmitter générique associé à une EventMap offre une type safety complète, car le compilateur vérifie que tu émets et écoutes exactement les données que tu as déclarées. Je te recommande de garder la carte des événements dans un seul fichier : c'est la meilleure documentation de la communication dans le système. Dans l'éditeur ci-dessous, tu construiras un système de surveillance des événements, et dans la leçon suivante, tu emmèneras TypeScript côté serveur avec Node.js.

Retiens : la carte des événements, c'est le plan des fréquences de la radio du parc, grâce auquel chaque gardien n'entend que des messages au format qu'il attend.

Code de cette leçon : index.ts
1// Système d'événements type-safe en TypeScript
2// Jurassic Park - Système de surveillance des événements
3
4// 1. EventMap - carte des types d'événements
5interface ParkEventMap {
6  'dinosaur:escaped': { species: string; paddock: string; dangerLevel: number };
7  'fence:status': { zone: string; voltage: number; active: boolean };
8  'visitor:enter': { visitorId: string; gate: string };
9  'alert:weather': { type: 'storm' | 'heat' | 'flood'; severity: 1 | 2 | 3 };
10}
11
12// 2. TypedEventEmitter générique
13class TypedEventEmitter<T extends Record<string, any>> {
14  private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
15
16  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
17    if (!this.listeners[event]) {
18      this.listeners[event] = [];
19    }
20    this.listeners[event]!.push(handler);
21  }
22
23  off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
24    const handlers = this.listeners[event];
25    if (handlers) {
26      const idx = handlers.indexOf(handler);
27      if (idx > -1) handlers.splice(idx, 1);
28    }
29  }
30
31  emit<K extends keyof T>(event: K, data: T[K]): void {
32    const handlers = this.listeners[event];
33    if (handlers) {
34      handlers.forEach(h => h(data));
35    }
36  }
37
38  once<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
39    const wrapper = (data: T[K]) => {
40      handler(data);
41      this.off(event, wrapper);
42    };
43    this.on(event, wrapper);
44  }
45}
46
47// 3. Utility types pour les événements
48type EventNames<T> = keyof T & string;
49type EventPayload<T, K extends keyof T> = T[K];
50type EventHandler<T, K extends keyof T> = (data: T[K]) => void;
51
52// 4. Test du système
53const parkEvents = new TypedEventEmitter<ParkEventMap>();
54
55// Enregistrement des handlers avec une sécurité de typage complète
56parkEvents.on('dinosaur:escaped', (data) => {
57  // TypeScript sait : data = { species, paddock, dangerLevel }
58  console.log(`ALARME ! ${data.species} s'est échappé de ${data.paddock} !`);
59  console.log(`   Niveau de danger : ${data.dangerLevel}/5`);
60});
61
62parkEvents.on('fence:status', (data) => {
63  const status = data.active ? 'ACTIVE' : 'DÉSACTIVÉE';
64  console.log(`Clôture ${data.zone} : ${status} (${data.voltage}V)`);
65});
66
67parkEvents.on('visitor:enter', (data) => {
68  console.log(`Le visiteur ${data.visitorId} est entré par la porte ${data.gate}`);
69});
70
71parkEvents.once('alert:weather', (data) => {
72  console.log(`Alerte météo (once) : ${data.type}, niveau ${data.severity}`);
73});
74
75// Émission des événements
76console.log("=== Système d'événements de Jurassic Park ===\n");
77
78parkEvents.emit('visitor:enter', {
79  visitorId: 'VIS-042',
80  gate: 'Principale'
81});
82
83parkEvents.emit('fence:status', {
84  zone: 'Raptor Paddock',
85  voltage: 15000,
86  active: true
87});
88
89parkEvents.emit('dinosaur:escaped', {
90  species: 'Velociraptor',
91  paddock: 'B-7',
92  dangerLevel: 4
93});
94
95parkEvents.emit('alert:weather', {
96  type: 'storm',
97  severity: 2
98});
99
100// La deuxième alerte ne déclenchera pas le handler (once !)
101parkEvents.emit('alert:weather', {
102  type: 'heat',
103  severity: 1
104});
105
106console.log("\n--- La deuxième alerte météo n'a pas été traitée (once) ---");
107
108// Le code ci-dessous ne compilerait PAS en TypeScript :
109// parkEvents.emit('dinosaur:escaped', { species: 'T-Rex' }); // paddock et dangerLevel manquants !
110// parkEvents.emit('unknown:event', {}); // Événement inconnu !
111
112console.log("\n=== Les événements type-safe fonctionnent ! ===");

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce qu'un EventMap dans le contexte d'un système d'événements type-safe ?

  2. 2. Quel avantage un TypedEventEmitter<T> générique apporte-t-il par rapport à un émetteur non typé ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Implémente un TypedEventEmitter générique avec les méthodes on, emit et off.

  • Éditeur de code

    Crée un EventMap avec des payloads typés et enregistre des handlers.

  • Tri horizontal

    Ordonne les éléments d'un appel emit dans un émetteur typé :

  • Tri vertical

    Ordonne les étapes d'évaluation du conditional type T extends U ? X : Y :

  • Tri horizontal

    Ordonne les éléments d'un conditional type avec infer :

Articles utiles