Émetteurs d'Événements Type-Safe en TypeScript
Vous avez déjà vécu cette situation : une faute de frappe dans un nom d’événement, une charge utile mal assortie, et soudain un écouteur ne se déclenche jamais en silence. Le bug prend 30 minutes à identifier, et la correction tient en un seul caractère. Les émetteurs d’événements typés en TypeScript éliminent toute cette catégorie de problèmes en déplaçant l’erreur du runtime vers le temps de compilation.
Cet article vous montre comment fonctionne le pattern de mappage d’événements TypeScript, quand construire votre propre abstraction plutôt que d’utiliser ce qui existe déjà, et quelques patterns pratiques qu’il est bon de connaître.
Points Clés
- Une interface de mappage d’événements agit comme source unique de vérité, reliant chaque nom d’événement à son type de charge utile exact au moment de la compilation.
- Une classe émettrice générique légère utilisant
K extends keyof TEventsdétecte les fautes de frappe et les incohérences de structure avant même que votre code ne s’exécute. - Les
@types/nodemodernes supportent déjà les mappages d’événements typés, donc une classe personnalisée n’est pas toujours nécessaire. - Associez toujours l’enregistrement des écouteurs à un nettoyage explicite pour éviter les fuites mémoire dans les services et composants UI de longue durée.
Pourquoi les EventEmitters Type-Safe Sont Importants
L’approche non typée ressemble à ceci :
import { EventEmitter } from 'events'
const emitter = new EventEmitter()
emitter.on('userLoggedIn', (user) => {
console.log(user.name) // user is `any`
})
emitter.emit('userLoggedin', { name: 'Alice' }) // typo — no error
Dans cette utilisation non typée, TypeScript ne détectera pas la différence de casse entre userLoggedIn et userLoggedin. L’écouteur ne se déclenche jamais, et rien ne vous indique pourquoi.
Le Pattern de Mappage d’Événements TypeScript
La solution commence par une interface simple — un mappage d’événements — où chaque clé est un nom d’événement et chaque valeur est son type de charge utile :
interface UserEvents {
userLoggedIn: { userId: string, timestamp: Date }
userLoggedOut: { userId: string }
profileUpdated: { userId: string, changes: Record<string, unknown> }
}
Cette interface devient la source unique de vérité pour tout ce que votre émetteur peut faire.
Construire un Émetteur Personnalisé Fortement Typé
Voici une classe d’émetteur typé minimale mais complète :
type EventCallback<T> = (payload: T) => void
class TypedEventEmitter<TEvents extends Record<string, unknown>> {
private listeners: {
[K in keyof TEvents]?: EventCallback<TEvents[K]>[]
} = {}
on<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
(this.listeners[event] ??= []).push(callback)
}
off<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
const cbs = this.listeners[event]
if (cbs) this.listeners[event] = cbs.filter(cb => cb !== callback)
}
emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
this.listeners[event]?.forEach(cb => cb(payload))
}
once<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
const wrapper: EventCallback<TEvents[K]> = (payload) => {
this.off(event, wrapper)
callback(payload)
}
this.on(event, wrapper)
}
}
Le mécanisme clé est K extends keyof TEvents. Lorsque vous appelez emit('userLoggedIn', ...), TypeScript restreint K à exactement 'userLoggedIn' et vérifie que la charge utile correspond à { userId: string, timestamp: Date }. Une faute de frappe ou une structure incorrecte devient une erreur de compilation.
const emitter = new TypedEventEmitter<UserEvents>()
emitter.on('userLoggedIn', ({ userId, timestamp }) => {
console.log(userId, timestamp) // fully typed
})
emitter.emit('userLoggedin', { userId: '1', timestamp: new Date() }) // ❌ compile error
emitter.emit('userLoggedIn', { userId: '1' }) // ❌ missing timestamp
Discover how at OpenReplay.com.
Utiliser EventEmitter de Node.js avec la Sécurité de Type
Les @types/node modernes exposent EventEmitter comme une classe générique, vous pouvez donc souvent obtenir des événements typés directement sans écrire de wrapper.
import { EventEmitter } from 'events'
interface AppEvents {
'user:login': [userId: string, email: string]
'data:sync': [recordCount: number]
}
const emitter = new EventEmitter<AppEvents>()
emitter.on('user:login', (userId, email) => {
console.log(userId, email)
})
emitter.emit('data:sync', 42)
Une mise en garde importante : le système d’événements de Node est synchrone par défaut — les écouteurs s’exécutent dans l’ordre d’enregistrement avant que emit ne retourne. De plus, l’événement 'error' a un comportement runtime spécial : s’il est émis sans écouteur attaché, Node lève une exception non capturée. Les utilitaires d’aide comme events.once() peuvent ne pas préserver complètement les types de votre mappage d’événements, donc la sécurité de type peut se dégrader dans ces cas.
Alternatives et Bibliothèques
Dans les contextes navigateur, EventTarget avec CustomEvent est l’option native, mais typer des sous-classes EventTarget personnalisées en TypeScript est maladroit — les contraintes génériques ne s’alignent pas proprement avec CustomEvent<T>.
Des bibliothèques comme mitt et eventemitter3 offrent des émetteurs typés prêts à l’emploi et méritent d’être considérées si vous voulez une implémentation éprouvée plutôt que de créer la vôtre. Elles sont optionnelles, non obligatoires — le pattern lui-même est suffisamment simple pour être géré directement.
Nettoyage Pratique : Éviter les Fuites Mémoire
Supprimez toujours les écouteurs lorsqu’ils ne sont plus nécessaires. Les écouteurs oubliés sont l’une des sources les plus courantes de fuites mémoire dans le code événementiel :
const handler = ({ userId }: { userId: string }) => console.log(userId)
emitter.on('userLoggedOut', handler)
// Later, in cleanup:
emitter.off('userLoggedOut', handler)
Dans React, associez ceci au nettoyage de useEffect. Dans tout service de longue durée, documentez quel composant est responsable de la suppression des écouteurs.
Conclusion
Le pattern de mappage d’événements TypeScript vous coûte une définition d’interface au départ. Ce que vous obtenez en retour, c’est l’application au moment de la compilation des noms d’événements et des structures de charge utile dans toute votre base de code — plus d’incohérences silencieuses, plus de callbacks typés any, et IntelliSense complet sur chaque écouteur. Pour tout système où les événements traversent les frontières de modules, ce compromis vaut la peine d’être fait immédiatement.
FAQ
Oui. Comme votre émetteur typé étend ou utilise toujours l'API EventEmitter de Node, il reste compatible avec tout code qui accepte une instance EventEmitter. Les contraintes de type sont appliquées uniquement au moment de la compilation et ne produisent aucun surcoût à l'exécution, donc les bibliothèques en aval continuent de fonctionner comme prévu.
Définissez la valeur de l'événement comme void dans votre mappage d'événements, par exemple statusCheck: void. Ensuite, ajustez votre signature emit pour rendre le paramètre de charge utile optionnel lorsque le type est void. Cela garde le site d'appel propre — vous pouvez appeler emit('statusCheck') sans passer d'argument.
TypeScript signalera le conflit partout où les types sont incompatibles. L'approche la plus sûre est de définir une seule interface de mappage d'événements partagée dans un module commun et de l'importer partout. Cela maintient tous les producteurs et consommateurs alignés sur une seule source de vérité.
Il peut bien fonctionner pour une messagerie légère dans la page entre micro-frontends. Cependant, à mesure que le nombre d'équipes indépendantes augmente, envisagez d'associer le pattern à un package de schéma partagé ou une étape de génération de code afin que le mappage d'événements reste synchronisé entre les applications déployées séparément.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.