Project: Strongly-Typed Event Emitter
JavaScript Event Emitters typically allow listening to or emitting any event with any payload type. In complex applications, this lack of typing makes it easy to dispatch incorrect arguments, leading to bugs.
In this project, we will design and implement a CustomEventEmitter that restricts event names and verifies that listener functions receive the correct parameter structures.
1. Defining the Event Map
First, we define an interface that maps event names to their payload structures.
interface AppEvents {
login: { userId: string; timestamp: number };
logout: void;
error: { code: number; message: string };
}This map acts as the single source of truth for all events in our system.
2. Implementing the Typed EventEmitter Class
We use the generic type variable Events which defaults to our event map interface. We use keyof Events to constrain event names and map the payload parameter types.
type Listener<T> = T extends void ? () => void : (payload: T) => void;
class CustomEventEmitter<Events> {
private listeners: Map<keyof Events, Function[]> = new Map();
public on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): void {
const list = this.listeners.get(event) || [];
list.push(listener);
this.listeners.set(event, list);
}
public emit<K extends keyof Events>(
...args: Events[K] extends void ? [event: K] : [event: K, payload: Events[K]]
): void {
const event = args[0];
const payload = args[1];
const list = this.listeners.get(event) || [];
list.forEach(listener => listener(payload));
}
}Explaining the Types:
- Listener: A conditional type that expects no arguments if the payload type is
void, or a single argument matching the payload. - emit: Uses a conditional rest parameter array. If the event has a payload type of
void, it only accepts the event name. Otherwise, it requires both the event name and the payload data.
3. Putting the Event Emitter to Use
Instantiate our emitter with the AppEvents interface map:
const emitter = new CustomEventEmitter<AppEvents>();
// 1. Listening to an event
emitter.on("login", (data) => {
// 'data' is automatically inferred as { userId: string; timestamp: number }
console.log(`User ${data.userId} logged in.`);
});
// 2. Emitting an event
emitter.emit("login", { userId: "user-99", timestamp: Date.now() });
// 3. Emitting a void event
emitter.emit("logout");
// 4. This will trigger compiler errors:
// emitter.emit("login", { userId: "invalid-user" }); // Error: Property 'timestamp' is missing
// emitter.emit("invalidEvent"); // Error: Event name is not assignable to 'keyof AppEvents'4. Summary
- Standard event listeners are untyped, leading to mismatch bugs.
- Create a central event interface map that maps event names to payload shapes.
- Use
keyof Eventsand conditional rest parameters to enforce argument checks during compilation.