Asked to build a simple event emitter in TypeScript. Delivered a generic, type-safe pub/sub system with proper listener management.
// A type-safe event emitter implementation
type EventMap = Record<string, unknown>;
type Listener<T> = (payload: T) => void;
interface IEventEmitter<TEvents extends EventMap> {
on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void;
off<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void;
emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void;
}
// Main event emitter class
export class EventEmitter<TEvents extends EventMap> implements IEventEmitter<TEvents> {
// Internal map of event names to listener sets
private readonly listeners: Map<keyof TEvents, Set<Listener<any>>> = new Map();
// Register a listener for a given event
public on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const bucket = this.listeners.get(event)!;
bucket.add(listener as Listener<any>);
// Return an unsubscribe function for convenience
return () => this.off(event, listener);
}
// Remove a previously registered listener
public off<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {
const bucket = this.listeners.get(event);
if (!bucket) return;
bucket.delete(listener as Listener<any>);
if (bucket.size === 0) {
this.listeners.delete(event);
}
}
// Emit an event with a payload to all registered listeners
public emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
const bucket = this.listeners.get(event);
if (!bucket) return;
for (const listener of Array.from(bucket.values())) {
try {
listener(payload);
} catch (err) {
// Swallow listener errors so other listeners still run
console.error(`[EventEmitter] listener for "${String(event)}" threw:`, err);
}
}
}
}
// Example usage
type AppEvents = {
login: { userId: string };
logout: { reason: string };
};
const bus = new EventEmitter<AppEvents>();
const unsub = bus.on("login", ({ userId }) => console.log(`User ${userId} logged in`));
bus.emit("login", { userId: "abc123" });
unsub();
Code Review
1. Lines 5-9. Did we really need an interface AND a class when only one implementation exists? This is the kind of thing that makes me sigh at 4pm.
2. Line 14. Set<Listener<any>> with `any` after spending 10 lines on generics is a bit of a self-own. The type safety leaks the moment it becomes inconvenient.
3. Line 16. "Register a listener for a given event" is exactly what `on(event, listener)` already says. Comment adds zero information.
4. Line 24. Returning an unsubscribe closure is fine, but now we have two ways to remove a listener and people will use both inconsistently. Pick one.
5. Line 41. `Array.from(bucket.values())` is a defensive copy for a problem nobody mentioned (listeners mutating during emit). If you meant to handle that, say so. Otherwise just iterate the Set.
6. Lines 43-47. Silently swallowing listener errors and console.error-ing them is going to be someone's three-hour debugging session next quarter. At least expose an onError hook.
7. Line 1. "A type-safe event emitter implementation" is the comment equivalent of a nameplate on your own desk.