{ ILoveJS }

Typed EventEmitter

typescript

A TypeScript EventEmitter class that provides complete type safety for event names and payloads using generics and mapped types, ensuring compile-time validation of all event operations.

typescripteventstypespatterns

Code

typescript
type EventMap = Record<string, unknown>;

type EventCallback<T> = (payload: T) => void;

type EventListeners<Events extends EventMap> = {
  [K in keyof Events]?: Set<EventCallback<Events[K]>>;
};

class TypedEventEmitter<Events extends EventMap> {
  private listeners: EventListeners<Events> = {};

  on<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): () => void {
    if (!this.listeners[event]) {
      this.listeners[event] = new Set();
    }
    this.listeners[event]!.add(callback);
    
    // Return unsubscribe function
    return () => this.off(event, callback);
  }

  off<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): void {
    this.listeners[event]?.delete(callback);
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach(callback => callback(payload));
  }

  once<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): () => void {
    const wrapper: EventCallback<Events[K]> = (payload) => {
      this.off(event, wrapper);
      callback(payload);
    };
    return this.on(event, wrapper);
  }

  removeAllListeners<K extends keyof Events>(event?: K): void {
    if (event) {
      delete this.listeners[event];
    } else {
      this.listeners = {};
    }
  }

  listenerCount<K extends keyof Events>(event: K): number {
    return this.listeners[event]?.size ?? 0;
  }
}

// Usage Example: Define your event types
interface AppEvents {
  userLoggedIn: { userId: string; timestamp: Date };
  userLoggedOut: { userId: string };
  messageReceived: { from: string; content: string; id: number };
  error: Error;
  ping: void;
}

// Create typed emitter instance
const emitter = new TypedEventEmitter<AppEvents>();

// Type-safe event subscription
const unsubscribe = emitter.on('userLoggedIn', (payload) => {
  // payload is typed as { userId: string; timestamp: Date }
  console.log(`User ${payload.userId} logged in at ${payload.timestamp}`);
});

// Type-safe emission - compiler enforces correct payload shape
emitter.emit('userLoggedIn', {
  userId: 'user-123',
  timestamp: new Date()
});

// Handle events with void payload
emitter.on('ping', () => {
  console.log('Pong!');
});
emitter.emit('ping', undefined as void);

// One-time listener
emitter.once('error', (error) => {
  console.error('First error caught:', error.message);
});

emitter.emit('error', new Error('Something went wrong'));

// Unsubscribe using returned function
unsubscribe();

// Check listener count
console.log('Login listeners:', emitter.listenerCount('userLoggedIn'));

// Clean up
emitter.removeAllListeners('messageReceived');
emitter.removeAllListeners();

How It Works

This TypedEventEmitter implementation leverages TypeScript's advanced type system to provide complete compile-time safety for event-driven programming. The foundation is the EventMap type alias, which constrains the Events generic to be a record mapping string keys to payload types. This pattern allows TypeScript to infer and enforce the correct payload type for each event name throughout all operations.

The key to type safety lies in the generic constraints on each method. When you call on<K extends keyof Events>(), TypeScript narrows K to the specific event name you provide, then uses that to look up the corresponding payload type via Events[K]. This indexed access type ensures that the callback parameter matches exactly what emit() will send. The same mechanism applies to off() and emit(), creating a closed type system where mismatched event names or payload shapes are caught at compile time rather than runtime.

The implementation uses Set<EventCallback<Events[K]>> for storing listeners, which provides O(1) add/delete operations and automatically handles duplicate prevention. The EventListeners type uses mapped types to create an object where each property is optional (since not all events may have listeners) and typed to the correct callback signature. The non-null assertion in on() is safe because we initialize the Set immediately before using it.

Several quality-of-life features enhance the API: the on() method returns an unsubscribe function for convenient cleanup without needing to keep callback references; once() wraps callbacks to auto-remove after first invocation; and removeAllListeners() supports both targeted and global cleanup. The listenerCount() method uses nullish coalescing to safely return 0 for events with no listeners.

This pattern is ideal for application-wide event buses, component communication in frameworks, or any scenario where you want guaranteed type safety for pub/sub patterns. Avoid it when you need dynamic event names determined at runtime (use a standard EventEmitter instead) or when the overhead of maintaining an event type interface becomes unwieldy. For large applications, consider splitting event interfaces by domain to keep type definitions manageable.