Building an EventEmitter
from scratch
The Observer pattern — register listeners, emit events, watch callbacks fire. The same pattern behind addEventListener, Node.js streams, Socket.IO, and every pub/sub system you've ever used.
Register, emit, observe
These buttons call the real EventEmitter from utils/eventEmitter.ts. The callbacks, registration, and removal all happen through the actual class — not simulated.
Implementation, typing, and real-world usage
A Map, four methods, and a generic type parameter. The same pattern powers half the libraries in your node_modules.
class EventEmitter {
private _registry = new Map<string, ((...args: any) => void)[]>();
on(event: string, callback: (...args: any) => void) {
if (!this._registry.has(event)) {
this._registry.set(event, []);
}
this._registry.get(event)!.push(callback);
}
off(event: string, callback: (...args: any) => void) {
if (this._registry.has(event)) {
const callbacks = this._registry.get(event)!;
const index = callbacks.findIndex((c) => c === callback);
if (index >= 0) callbacks.splice(index, 1);
}
}
once(event: string, callback: (...args: any) => void) {
const fn = (...args: any) => {
callback(...args);
this.off(event, fn); // auto-remove after first call
};
this.on(event, fn);
}
emit(event: string, ...args: any) {
this._registry.get(event)?.forEach((cb) => cb(...args));
}
}once() wraps the original callback in a new function that calls off() on itself after firing. The wrapper has a different reference than the original, so off() removes the right function. This wrapper pattern appears everywhere — in debounce, throttle, memoize, React.memo, middleware chains.
Every on() without a matching off() is a potential memory leak. In React, this means cleaning up in useEffect return functions. In Node.js, it means calling removeListener when connections close. Node warns you at 11+ listeners on a single event — that's usually a leak.