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:
- Only valid event names (defined by the developer) can be subscribed to.
- 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 giveneventName. Thelistenerfunction will be called with thepayloadof the event, which should be of the typeT[K].off<K extends keyof T>(eventName: K, listener: (payload: T[K]) => void): void: Removes a specific listener function for a giveneventName.emit<K extends keyof T>(eventName: K, payload: T[K]): void: Emits an event with the giveneventNameandpayload. 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
EventEmitterclass should be generic, accepting a type parameterTrepresenting the event map. - The keys of
Trepresent event names (strings), and the values ofTrepresent 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
EventEmitterimplementation.
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 Tconstraint 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
offis 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).