Back

Typsichere Event-Emitter in TypeScript

Typsichere Event-Emitter in TypeScript

Sie kennen das: Ein Tippfehler im Event-Namen, eine nicht übereinstimmende Payload, und plötzlich wird ein Listener nie ausgelöst. Der Bug kostet 30 Minuten Debugging-Zeit, und die Lösung ist ein einziges Zeichen. Typisierte Event-Emitter in TypeScript eliminieren diese gesamte Problemkategorie, indem sie den Fehler von der Laufzeit in die Kompilierzeit verlagern.

Dieser Artikel zeigt Ihnen, wie das TypeScript Event-Map-Pattern funktioniert, wann Sie Ihre eigene Abstraktion erstellen sollten anstatt auf Bestehendes zurückzugreifen, und einige praktische Patterns, die Sie kennen sollten.

Wichtigste Erkenntnisse

  • Ein Event-Map-Interface fungiert als Single Source of Truth und verknüpft jeden Event-Namen zur Kompilierzeit mit seinem exakten Payload-Typ.
  • Eine leichtgewichtige generische Emitter-Klasse mit K extends keyof TEvents erkennt Tippfehler und Strukturfehler, bevor Ihr Code überhaupt ausgeführt wird.
  • Moderne @types/node unterstützen bereits typisierte Event-Maps, sodass eine eigene Klasse nicht immer notwendig ist.
  • Kombinieren Sie die Listener-Registrierung immer mit explizitem Cleanup, um Memory Leaks in langlebigen Services und UI-Komponenten zu vermeiden.

Warum typsichere EventEmitter wichtig sind

Der untypisierte Ansatz sieht so aus:

import { EventEmitter } from 'events'

const emitter = new EventEmitter()

emitter.on('userLoggedIn', (user) => {
  console.log(user.name) // user ist `any`
})

emitter.emit('userLoggedin', { name: 'Alice' }) // Tippfehler — kein Fehler

Bei dieser untypisierten Verwendung erkennt TypeScript den Unterschied in der Groß-/Kleinschreibung zwischen userLoggedIn und userLoggedin nicht. Der Listener wird nie ausgelöst, und nichts sagt Ihnen warum.

Das TypeScript Event-Map-Pattern

Die Lösung beginnt mit einem einfachen Interface – einer Event-Map – bei der jeder Key ein Event-Name und jeder Value dessen Payload-Typ ist:

interface UserEvents {
  userLoggedIn: { userId: string, timestamp: Date }
  userLoggedOut: { userId: string }
  profileUpdated: { userId: string, changes: Record<string, unknown> }
}

Dieses Interface wird zur Single Source of Truth für alles, was Ihr Emitter tun kann.

Erstellen eines stark typisierten Custom Emitters

Hier ist eine minimale, aber vollständige typisierte Emitter-Klasse:

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)
  }
}

Der Schlüsselmechanismus ist K extends keyof TEvents. Wenn Sie emit('userLoggedIn', ...) aufrufen, grenzt TypeScript K exakt auf 'userLoggedIn' ein und prüft, ob die Payload mit { userId: string, timestamp: Date } übereinstimmt. Ein Tippfehler oder eine falsche Struktur wird zu einem Kompilierfehler.

const emitter = new TypedEventEmitter<UserEvents>()

emitter.on('userLoggedIn', ({ userId, timestamp }) => {
  console.log(userId, timestamp) // vollständig typisiert
})

emitter.emit('userLoggedin', { userId: '1', timestamp: new Date() }) // ❌ Kompilierfehler
emitter.emit('userLoggedIn', { userId: '1' }) // ❌ timestamp fehlt

Node.js EventEmitter mit Typsicherheit verwenden

Moderne @types/node stellen EventEmitter als generische Klasse bereit, sodass Sie oft typisierte Events direkt erhalten können, ohne einen Wrapper zu schreiben.

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)

Ein wichtiger Hinweis: Das Event-System von Node ist standardmäßig synchron – Listener werden in der Reihenfolge ihrer Registrierung ausgeführt, bevor emit zurückkehrt. Außerdem hat das 'error'-Event ein spezielles Laufzeitverhalten: Wird es ohne angehängten Listener emittiert, wirft Node eine Uncaught Exception. Hilfsfunktionen wie events.once() bewahren möglicherweise nicht vollständig die Typen Ihrer Event-Map, sodass die Typsicherheit in diesen Fällen abnehmen kann.

Alternativen und Bibliotheken

In Browser-Kontexten ist EventTarget mit CustomEvent die native Option, aber das Typisieren von benutzerdefinierten EventTarget-Subklassen in TypeScript ist umständlich – die generischen Constraints passen nicht sauber zu CustomEvent<T>.

Bibliotheken wie mitt und eventemitter3 bieten typisierte Emitter out of the box und sind eine Überlegung wert, wenn Sie eine kampferprobte Implementierung bevorzugen, anstatt Ihre eigene zu entwickeln. Sie sind optional, nicht erforderlich – das Pattern selbst ist einfach genug, um es direkt zu implementieren.

Praktisches Cleanup: Memory Leaks vermeiden

Entfernen Sie Listener immer, wenn sie nicht mehr benötigt werden. Vergessene Listener sind eine der häufigsten Ursachen für Memory Leaks in event-getriebenem Code:

const handler = ({ userId }: { userId: string }) => console.log(userId)

emitter.on('userLoggedOut', handler)

// Später, beim Cleanup:
emitter.off('userLoggedOut', handler)

In React kombinieren Sie dies mit useEffect-Cleanup. In jedem langlebigen Service dokumentieren Sie, welche Komponente für das Entfernen der Listener verantwortlich ist.

Fazit

Das TypeScript Event-Map-Pattern kostet Sie vorab eine Interface-Definition. Was Sie zurückbekommen, ist eine Kompilierzeit-Durchsetzung von Event-Namen und Payload-Strukturen in Ihrer gesamten Codebasis – keine stillen Diskrepanzen mehr, keine any-typisierten Callbacks mehr und vollständiges IntelliSense für jeden Listener. Für jedes System, in dem Events Modulgrenzen überschreiten, lohnt sich dieser Trade-off sofort.

FAQs

Ja. Da Ihr typisierter Emitter die EventEmitter-API von Node erweitert oder verwendet, bleibt er mit jedem Code kompatibel, der eine EventEmitter-Instanz akzeptiert. Die Typ-Constraints werden nur zur Kompilierzeit durchgesetzt und erzeugen keinen Laufzeit-Overhead, sodass nachgelagerte Bibliotheken weiterhin wie erwartet funktionieren.

Definieren Sie den Wert des Events in Ihrer Event-Map als void, zum Beispiel statusCheck: void. Passen Sie dann Ihre emit-Signatur an, um den Payload-Parameter optional zu machen, wenn der Typ void ist. Das hält die Aufrufstelle sauber – Sie können emit('statusCheck') ohne Argument aufrufen.

TypeScript wird den Konflikt überall dort markieren, wo die Typen inkompatibel sind. Der sicherste Ansatz besteht darin, ein einzelnes gemeinsames Event-Map-Interface in einem gemeinsamen Modul zu definieren und es überall zu importieren. Dadurch bleiben alle Producer und Consumer auf eine Single Source of Truth ausgerichtet.

Er kann gut für leichtgewichtiges In-Page-Messaging zwischen Micro-Frontends funktionieren. Wenn jedoch die Anzahl unabhängiger Teams wächst, sollten Sie das Pattern mit einem gemeinsamen Schema-Package oder einem Code-Generierungsschritt kombinieren, damit die Event-Map über separat deployte Anwendungen hinweg synchronisiert bleibt.

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.

OpenReplay