Step 1: Requirements
on(event, listener)โ subscribe; return unsubscribe functionoff(event, listener)โ unsubscribeemit(event, ...args)โ notify all listeners for this eventonce(event, listener)โ subscribe, auto-remove after one callremoveAll(event?)โ remove all listeners (optional: for a specific event)- TypeScript generics for type-safe event maps
Step 2: Basic implementation
type Listener<T extends any[]> = (...args: T) => void;
class EventEmitter {
private listeners = new Map<string, Set<Listener<any[]>>>();
on<T extends any[]>(event: string, fn: Listener<T>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(fn as Listener<any[]>);
// Return unsubscribe function
return () => this.off(event, fn);
}
off<T extends any[]>(event: string, fn: Listener<T>): void {
this.listeners.get(event)?.delete(fn as Listener<any[]>);
}
emit<T extends any[]>(event: string, ...args: T): void {
this.listeners.get(event)?.forEach(fn => fn(...args));
}
once<T extends any[]>(event: string, fn: Listener<T>): () => void {
const wrapper: Listener<T> = (...args) => {
fn(...args);
this.off(event, wrapper); // auto-remove after first call
};
return this.on(event, wrapper);
}
removeAll(event?: string): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
listenerCount(event: string): number {
return this.listeners.get(event)?.size ?? 0;
}
}
Step 3: Type-safe event map (bonus โ often asked as follow-up)
// Constrain events and their payload types using a generic map
type EventMap = Record<string, any[]>;
class TypedEventEmitter<Events extends EventMap> {
private listeners = new Map<keyof Events, Set<(...args: any[]) => void>>();
on<K extends keyof Events>(event: K, fn: (...args: Events[K]) => void): () => void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(fn);
return () => this.off(event, fn);
}
off<K extends keyof Events>(event: K, fn: (...args: Events[K]) => void): void {
this.listeners.get(event)?.delete(fn);
}
emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
this.listeners.get(event)?.forEach(fn => fn(...args));
}
once<K extends keyof Events>(event: K, fn: (...args: Events[K]) => void): () => void {
const wrapper = (...args: Events[K]) => { fn(...args); this.off(event, wrapper); };
return this.on(event, wrapper);
}
}
// Usage โ fully type-safe
type AppEvents = {
login: [userId: string, timestamp: number];
logout: [userId: string];
error: [message: string, code: number];
};
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on('login', (userId, timestamp) => {
// TypeScript knows userId: string, timestamp: number
console.log(`User ${userId} logged in at ${timestamp}`);
});
emitter.emit('login', 'u_123', Date.now()); // โ
emitter.emit('login', 'u_123'); // โ TypeScript error โ missing timestamp
emitter.emit('unknown', 'data'); // โ TypeScript error โ unknown event
Step 4: Edge cases to talk through
// Edge case 1: emitting inside a listener (mutation during iteration)
// Using Set protects us โ iterating over Set is safe against .delete() calls
// during iteration (the deleted item won't appear again, but the current
// iteration isn't corrupted). However, to be safe, snapshot before iterating:
emit<T extends any[]>(event: string, ...args: T): void {
const fns = this.listeners.get(event);
if (!fns) return;
// Snapshot to protect against listener removing itself mid-emit
[...fns].forEach(fn => fn(...args));
}
// Edge case 2: once() unsubscribe before it fires
const unsub = emitter.once('login', handler);
unsub(); // removes the wrapper before it can fire
// Edge case 3: max listeners warning (Node.js does this โ mention it)
private maxListeners = 10;
on(event, fn) {
// ...
if (this.listeners.get(event)!.size > this.maxListeners) {
console.warn(`MaxListenersExceeded: ${event} has > ${this.maxListeners} listeners โ possible leak`);
}
// ...
}
Tests to validate your implementation
const ee = new EventEmitter();
let callCount = 0;
// on/emit
const unsub = ee.on('data', (x) => { callCount += x; });
ee.emit('data', 5);
ee.emit('data', 3);
console.assert(callCount === 8, 'on/emit');
// off via unsubscribe function
unsub();
ee.emit('data', 100);
console.assert(callCount === 8, 'unsubscribe works');
// once
let onceFired = 0;
ee.once('tick', () => onceFired++);
ee.emit('tick');
ee.emit('tick');
ee.emit('tick');
console.assert(onceFired === 1, 'once fires exactly once');
// removeAll
ee.on('x', () => {});
ee.on('x', () => {});
ee.removeAll('x');
console.assert(ee.listenerCount('x') === 0, 'removeAll');
Say it out loud
โI store listeners in a Map<string, Set<Listener>>. on adds to the Set and returns an unsubscribe closure. off deletes from the Set. emit iterates a snapshot of the Set (to safely handle self-removal). once wraps the listener in a function that calls the original then removes the wrapper. The typed version uses a generic EventMap so TypeScript enforces both the event name and its payload types at call sites.โ