Typsichere Event-Emitter in TypeScript
Ersetze Nodes stringbasierten EventEmitter durch eine generische, typisierte Alternative, die falsche Eventnamen und Payloads beim Kompilieren findet.

Der EventEmitter von Node gehört zu den meistgenutzten Bausteinen in serverseitigem JavaScript. Er ist aber auch ein Minenfeld für Refactorings: Jeder Eventname ist ein String, jede Payload ist any, und nichts hindert dich daran, ein Event zu emittieren, auf das niemand hört, oder einen Listener anzuschließen, der die falsche Struktur erwartet. TypeScript kann all das bereits zur Kompilierzeit erzwingen, und die Einrichtung ist einfacher, als die meisten Teams annehmen.
Das Problem mit Nodes EventEmitter
Die Standard-API bietet keinerlei Typsicherheit für Eventnamen oder Payloads.
// ❌ TypeScript accepts all of this — none of it is checked
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
emitter.on("user:created", (user: { id: string; email: string }) => {
sendWelcomeEmail(user.email);
});
// Typo — listener above never fires, no compile error, no runtime warning
emitter.emit("user:create", { id: "1", email: "test@example.com" });
// Wrong payload shape — crashes inside the listener at runtime
emitter.emit("user:created", { userId: "1", address: "123 Main St" });Diese Bugs überstehen den Code-Review, bestehen das Linting und tauchen erst in Produktion auf. Wenn ein Emitter Modulgrenzen überschreitet – emittiert in einer Service-Schicht, konsumiert in einem Handler –, macht die Distanz zwischen Ursache und Absturz die Diagnose teuer.
Eine Event-Map entwerfen
Die Grundlage eines typisierten Emitters ist ein Interface, das Eventnamen auf Payload-Typen abbildet. Jedes Event, das dein System auslösen kann, bekommt hier einen Eintrag.
// events.ts — single source of truth for your event contract
interface AppEvents {
"user:created": { id: string; email: string; createdAt: Date };
"user:deleted": { id: string; reason: "self" | "admin" | "inactivity" };
"order:placed": { orderId: string; userId: string; totalCents: number };
"order:fulfilled": { orderId: string; trackingCode: string };
"payment:failed": { orderId: string; code: string; retryable: boolean };
}Diese Map wird zum Typparameter, den du durch deinen Emitter reichst. Ein neues Event hinzuzufügen bedeutet, hier einen Eintrag zu ergänzen – jede Aufrufstelle, die den falschen Namen oder die falsche Struktur referenziert, bekommt automatisch einen Compilerfehler.
Den typisierten Emitter bauen
Beginne mit dem Interface und implementiere es anschließend.
type EventHandler<T> = (payload: T) => void | Promise<void>;
interface TypedEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this;
off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this;
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this;
emit<K extends keyof Events>(event: K, payload: Events[K]): boolean;
listenerCount<K extends keyof Events>(event: K): number;
}
class TypedEventEmitter<Events extends Record<string, unknown>>
implements TypedEmitter<Events>
{
private handlers = new Map<keyof Events, Set<EventHandler<unknown>>>();
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as EventHandler<unknown>);
return this;
}
off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
this.handlers.get(event)?.delete(handler as EventHandler<unknown>);
return this;
}
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
const wrapper: EventHandler<Events[K]> = (payload) => {
this.off(event, wrapper);
return handler(payload);
};
return this.on(event, wrapper);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): boolean {
const listeners = this.handlers.get(event);
if (!listeners || listeners.size === 0) return false;
for (const handler of listeners) {
handler(payload);
}
return true;
}
listenerCount<K extends keyof Events>(event: K): number {
return this.handlers.get(event)?.size ?? 0;
}
}Die generische Constraint K extends keyof Events ist der entscheidende Kniff. Sie sagt TypeScript, dass das Handler-Argument von on mit dem Payload-Typ kompatibel sein muss, auf den K in Events verweist. Tippfehler in Eventnamen schlagen an der Aufrufstelle fehl, nicht erst zur Laufzeit.
Aufrufstellen sehen jetzt so aus:
// ✅ Payload type is fully inferred — no annotation needed on the handler
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("user:created", (user) => {
// user: { id: string; email: string; createdAt: Date }
sendWelcomeEmail(user.email);
});
// ✅ Compile error: Argument of type '"user:create"' is not assignable to keyof AppEvents
// emitter.emit("user:create", { id: "1", email: "test@example.com" });
// ✅ Compile error: Object literal may only specify known properties
// emitter.emit("user:created", { userId: "1" });
emitter.emit("user:created", {
id: "usr_01j2k",
email: "new@example.com",
createdAt: new Date(),
});Asynchrone Listener sicher behandeln
Nodes eingebauter EventEmitter verwirft Promise-Rejections aus Listenern stillschweigend – sie werden zu nicht behandelten Rejections, ohne jeden Hinweis darauf, welches Event sie verursacht hat. Eine echte Strategie in emit einzubauen kostet fast nichts.
emit<K extends keyof Events>(event: K, payload: Events[K]): boolean {
const listeners = this.handlers.get(event);
if (!listeners || listeners.size === 0) return false;
for (const handler of listeners) {
try {
const result = handler(payload);
if (result instanceof Promise) {
result.catch((err: unknown) => {
// Surface async errors with event context instead of swallowing them
console.error(
`Unhandled async error in listener for "${String(event)}":`,
err,
);
});
}
} catch (err) {
// Prevent one bad synchronous handler from breaking remaining listeners
console.error(
`Synchronous error in listener for "${String(event)}":`,
err,
);
}
}
return true;
}Jeden Aufruf in try/catch einzupacken verhindert, dass ein fehlerhafter Handler die übrigen Listener desselben Events stillschweigend abbricht. Ob du den Fehler loggst, ihn an einen Error-Channel weiterleitest oder erneut wirfst, hängt von deiner Fehlerstrategie ab – aber ihn einfach zu verschlucken ist nie die richtige Standardwahl.
Emitter von Drittanbietern kapseln
Du hast nicht immer die Kontrolle über den Emitter. Datenbank-Treiber, Message-Queue-Clients und File-Watcher legen häufig rohe EventEmitter-Instanzen offen. Kapsle sie an der Grenze, statt untypisierte .on()-Aufrufe über die gesamte Codebasis zu verteilen.
import type { Consumer } from "kafkajs";
interface KafkaConsumerEvents {
message: { topic: string; partition: number; value: Buffer | null };
error: { error: Error };
rebalancing: { type: "assign" | "revoke" };
}
function wrapKafkaConsumer(consumer: Consumer): TypedEmitter<KafkaConsumerEvents> {
const emitter = new TypedEventEmitter<KafkaConsumerEvents>();
consumer.on(consumer.events.GROUP_JOIN, ({ payload }) => {
emitter.emit("rebalancing", { type: "assign" });
});
consumer.on(consumer.events.CRASH, ({ payload }) => {
emitter.emit("error", { error: payload.error });
});
return emitter;
}Der Wrapper wandelt die schwach typisierten Upstream-Events in deinen typisierten Vertrag um. Konsumenten von wrapKafkaConsumer arbeiten ausschließlich über das typisierte Interface – sie fassen weder das rohe Consumer-Objekt noch seine stringbasierten Event-Konstanten an.
Die wichtigsten Erkenntnisse
- Event-Maps sind dein Vertrag — definiere sie einmal und lass den Compiler jede Aufrufstelle prüfen. Ein Event hinzuzufügen ist eine Zeile; eines zu entfernen deckt automatisch jede veraltete Referenz auf.
K extends keyof Eventsist das Muster — diese generische Constraint taucht in typisierten Emittern, Router-Buildern, Store-Selektoren und überall dort auf, wo du eine erschöpfende Schlüsselprüfung willst. Verinnerliche sie.- Implementiere
oncemithilfe vononundoff— das hält die Speicherverwaltung konsistent und vermeidet abweichende Cleanup-Logik. - Asynchrone Fehler brauchen eine explizite Strategie — dass ein Listener eine Promise zurückgibt, heißt nicht, dass Rejections irgendwo sinnvoll ankommen. Lege eine Policy fest und codiere sie direkt in
emit. - Kapsle Emitter von Drittanbietern an der Integrationsgrenze — rohe Library-Events sind Nahtstellen, keine internen Domain-Events. Das Kapseln entkoppelt deinen Code von library-spezifischen Konstanten und macht den Event-Vertrag explizit.


