Create a typed event emitter
We can make use of node's built-in EventEmitter
when we want to create a custom event emitter. However, it is not type-safe. We can use TypeScript to create a typed event emitter.
Use case
The chart below showcase the very simple use case of the event emitter.Once the users click the subscribe, the event emitter will add a new listener to the event. When there is a new news, the event emitter will emit the event and the listener will be called.
loading...Scaffold
You can extend the EventEmitter
class from events
module in node. In this case, we will create our own one named TypedEventEmitter
because I would like to just demo the most important 2 methods: on
and emit
.
The following code shows the basic structure of the event emitter:
export class TypedEventEmitter {
private eventListeners: Record<string, Array<Function>> = {};
on(eventName: string, callback: (payload: unknown) => void) {}
emit(eventName: string, payload: unknown) {}
}
As can be seen, there are unknown
types in the on
and emit
methods. At the same time, using Function
type is not recommended.
So we will refactor the code to make it more type-safe.
Generic with type constraints
The first step is introducing the generic type to the class. The generic type will be used to define the event map. The event map is a record type that defines the event name and the payload type.
Now, in on
and emit
methods, we can use the generic type to define the type of the event name and the payload.
export class TypedEventEmitter {
export class TypedEventEmitter<T extends Record<string, any[]>> {
private eventListeners: Record<string, Array<Function>> = {};
on(eventName: string, callback: (payload: unknown) => void) {}
on<U extends keyof T>(eventName: U, callback: (payload: T[U]) => void) {}
emit(eventName: string, payload: unknown) {}
emit<U extends keyof T>(eventName: U, payload: T[U]) {}
}
Mapped types
The next step is to refactor the eventListeners
property.
As mentioned, using Function
type is not explicit enough. So we can also use our defined generic type to explicitly tell:
- The event name is a key of the event map (generic type T's key)
- The listener will be an array of function with the payload of event map (generic type T's value) as arguments
export class TypedEventEmitter<T extends Record<string, any[]>> {
private eventListeners: Record<string, Array<Function>> = {};
private _eventListeners: { [Key in keyof T]?: Array<(...payload: T[Key]) => void> } = {};
on<U extends keyof T>(eventName: U, callback: (...payload: T[U]) => void) {}
emit<U extends keyof T>(eventName: U, ...payload: T[U]) {}
}
Final refector
We can extract the listener callback type to a separate type to make the code more readable.
type ListenerCallback<T extends Array<any>> = (...payload: T) => void;
export class TypedEventEmitter<T extends Record<string, Array<any>>> {
private _eventListeners: { [Key in keyof T]?: Array<(...payload: T[Key]) => void> } = {};
private _eventListeners: { [Key in keyof T]?: Array<ListenerCallback<T[Key]>> } = {};
on<U extends keyof T>(eventName: U, callback: (...payload: T[U]) => void) {}
on<U extends keyof T>(eventName: U, callback: ListenerCallback<T[U]>) {
const listeners = this._eventListeners[eventName] ?? [];
listeners.push(callback);
this._eventListeners[eventName] = listeners;
}
emit<U extends keyof T>(eventName: U, ...payloads: T[U]) {
const callbacks = this._eventListeners[eventName];
callbacks?.forEach((callback) => callback(...payloads));
}
}
Implement the event emitter logic
Now we can implement the event emitter logic.
export class TypedEventEmitter<T extends Record<string, any[]>> {
private _eventListeners: { [Key in keyof T]?: Array<(...payload: T[Key]) => void> } = {};
on<U extends keyof T>(eventName: U, callback: (...payload: T[U]) => void) {
const listeners = this._eventListeners[eventName] ?? [];
listeners.push(callback);
this._eventListeners[eventName] = listeners;
}
emit<U extends keyof T>(eventName: U, ...payloads: T[U]) {
const callbacks = this._eventListeners[eventName];
callbacks?.forEach((callback) => callback(...payloads));
}
}
Test the event emitter
We can test the event emitter with the concept that we have introduced in the previous section, users can subscribe to the news and when there is a new news, the event emitter will emit the event and the listener will be called.
// Define the event map types
type NewsEvents = {
subscribe: [string];
unsubscribe: [string];
};
// Create a new instance of the event emitter
const newsEmitter = new TypedEventEmitter<NewsEvents>();
// Subscribe to the event
newsEmitter.on("subscribe", (news) => {
console.log(news);
});
// Emit the event
newsEmitter.emit("subscribe", "Hello World News");
You will see it will be able to infer the type of the not only the event name but also the payload. But, You will see the following error:
TSError: ⨯ Unable to compile TypeScript:
lib/index.ts(8,20): error TS2345: Argument of type 'ListenerCallback<T[U]>' is not assignable to parameter of type 'never'.
The reason is that the on
method is not able to infer the type of the eventName. So, Two solutions here:
- Conditional check if listeners is undefined
// ...
on<U extends keyof T>(eventName: U, callback: ListenerCallback<T[U]>) {
const listeners = this._eventListeners[eventName];
if (listeners) {
return listeners.push(callback);
}
this._eventListeners[eventName] = [callback];
}
// ...
- Explicitly define the type of the empty array type when the listeners is undefined.
// ...
on<U extends keyof T>(eventName: U, callback: ListenerCallback<T[U]>) {
const listeners = this._eventListeners[eventName] ?? ([] as Array<ListenerCallback<T[U]>>);
listeners.push(callback);
this._eventListeners[eventName] = listeners;
}
// ...
But The best solution is replacing Array with Set.
on<U extends keyof T>(eventName: U, callback: ListenerCallback<T[U]>) {
const listeners = this._eventListeners[eventName] ?? new Set();
listeners.add(callback);
this._eventListeners[eventName] = listeners;
}
The reason is that Set is a collection of unique values. So, We don't need to worry if the callback is already in the listeners.
Congratulations! We can now use the event emitter with the type safety.