Hone logo
Hone
Problems

Build a Type-Safe Event Emitter in TypeScript

Creating a robust and maintainable event emitter is a common requirement in many JavaScript and TypeScript applications. This challenge focuses on building an event emitter that leverages TypeScript's type system to ensure type safety, preventing runtime errors related to incorrect event names or data payloads.

Problem Description

You are tasked with implementing a generic EventEmitter class in TypeScript. This class should allow developers to register listeners for specific event names and emit those events with associated data. The primary goal is to enforce type safety, meaning that the TypeScript compiler should verify that:

  1. Only valid event names (defined by the developer) can be subscribed to.
  2. When an event is emitted, the data passed to its listeners must match the expected type for that specific event.

The EventEmitter class should have the following methods:

  • on<K extends keyof T>(eventName: K, listener: (payload: T[K]) => void): void: Registers a listener function for a given eventName. The listener function will be called with the payload of the event, which should be of the type T[K].
  • off<K extends keyof T>(eventName: K, listener: (payload: T[K]) => void): void: Removes a specific listener function for a given eventName.
  • emit<K extends keyof T>(eventName: K, payload: T[K]): void: Emits an event with the given eventName and payload. This should trigger all registered listeners for that event.

Consider how to handle cases where an event has no listeners or when a listener is registered multiple times for the same event.

Examples

Example 1: Basic Usage

// Define the event types
type AppEvents = {
  'userLoggedIn': { userId: string; timestamp: number };
  'dataUpdated': string[];
};

// Instantiate the EventEmitter
const emitter = new EventEmitter<AppEvents>();

// Define listeners
const loginListener = (payload: { userId: string; timestamp: number }) => {
  console.log(`User logged in: ${payload.userId} at ${new Date(payload.timestamp).toISOString()}`);
};

const dataListener = (payload: string[]) => {
  console.log(`Data updated: ${payload.join(', ')}`);
};

// Register listeners
emitter.on('userLoggedIn', loginListener);
emitter.on('dataUpdated', dataListener);

// Emit events
emitter.emit('userLoggedIn', { userId: 'user123', timestamp: Date.now() });
emitter.emit('dataUpdated', ['item1', 'item2', 'item3']);

// Expected Output:
// User logged in: user123 at <current ISO timestamp>
// Data updated: item1, item2, item3

Example 2: Type Mismatch Error (Compile-time)

If you try to emit an event with an incorrect payload type, TypeScript should flag it as an error.

type AppEvents = {
  'userLoggedIn': { userId: string; timestamp: number };
};

const emitter = new EventEmitter<AppEvents>();

// This will cause a TypeScript compilation error:
// Argument of type '{ userId: string; }' is not assignable to parameter of type '{ userId: string; timestamp: number; }'.
// emitter.emit('userLoggedIn', { userId: 'user456' });

Example 3: Removing Listeners

type AppEvents = {
  'message': string;
};

const emitter = new EventEmitter<AppEvents>();

const handler1 = (msg: string) => console.log('Handler 1:', msg);
const handler2 = (msg: string) => console.log('Handler 2:', msg);

emitter.on('message', handler1);
emitter.on('message', handler2);

emitter.emit('message', 'Hello'); // Output: Handler 1: Hello, Handler 2: Hello

emitter.off('message', handler1);

emitter.emit('message', 'World'); // Output: Handler 2: World

Constraints

  • The EventEmitter class should be generic, accepting a type parameter T representing the event map.
  • The keys of T represent event names (strings), and the values of T represent the expected payload types for those events.
  • The implementation should be efficient, especially for large numbers of events and listeners.
  • No external libraries are allowed for the core EventEmitter implementation.

Notes

  • Think carefully about how to map event names to their corresponding listener functions and payloads.
  • Consider using a data structure that allows for efficient addition, removal, and iteration of listeners.
  • The keyof T constraint is crucial for ensuring that only defined event names can be used.
  • The generic type T[K] is essential for type-checking the payload.
  • Consider what happens if off is called with a listener that was never registered or with an event name that doesn't exist. The behavior should be graceful (e.g., no error).
Loading editor...
typescript