TypeScript implements type safe EventEmitter

2023-03-07 635 Reading

Tips: This article has exceeded four hundred and sixty-four No update in days, please note whether relevant content is still available!

Recently, more and more personal projects use EventEmitter modules. Because the types are not safe enough, you should be careful when writing them. So we plan to improve it by implementing TypeScript type safe EventEmitter to solve the problem that event names and function types cannot be verified.

The EventEmitter of Nodejs is a publish subscribe module.

With this class, we can monitor events. The monitored object will trigger events at the appropriate time and call the methods provided by the listening object. It is a common implementation of decoupling between modules.

With the increasingly popular TypeScript, we can install @types/node , we can further obtain type capability and reduce the occurrence of low-level errors. However, the type implementation of EventEmitter is not excellent, so it is not type safe.

Generally speaking, the response function types corresponding to different events are different, but @types/node Of The EventEmiiter type does not provide an advanced type, but an exceptionally loose value

 class EventEmitter {   constructor(options?: EventEmitterOptions); //The type is too broad   on(eventName: string | symbol, listener: (...args: any[]) => void): this;   emit(eventName: string | symbol, ...args: any[]): boolean;   // ... other }

As you can see, the type of event name passed in by the on method is string | symbol , listener is a function of any type. The parameter passed in by emit is also any[]

Because the type is too loose, TypeScript will not report errors if the event name is misspelled. When an eventEmitter has many event types, we are no different from naked JavaScript.

Do it yourself, have plenty of food and clothing, we might as well Implement a type safe EventEmitter

EventEmitter Implementation

Because I actually use EventEmitter on the front end, I wrote a simple JavaScript implementation of EventEmitter.

 class EventEmitter {   eventMap = {}; //Add the listener function of the corresponding event   on(eventName, listener) { if (!this.eventMap[eventName]) {   this.eventMap[eventName] = []; } this.eventMap[eventName].push(listener); return this;   } //Trigger Event   emit(eventName, ...args) { const listeners = this.eventMap[eventName]; if (!listeners || listeners.length === 0) return false; listeners.forEach((listener) => {   listener(...args); }); return true;   } //Cancel listening to the corresponding event   off(eventName, listener) { const listeners = this.eventMap[eventName]; if (listeners && listeners.length > 0) {   const index = listeners.indexOf(listener);   if (index > -1) { listeners.splice(index, 1);   } } return this;   } }

If you are nodejs, it may be better to inherit EventEmitter and change its type, or you can implement one "based on composition rather than inheritance".

Type safe EventEmitter

Next, change the above code to TypeScript.

We hope that the effect is:

 const ee = new EventEmitter<{   update(newVal: string, prevVal: string): void;   destroy(): void; }>(); const handler = (newVal: string, prevVal: string) => {   console.log(newVal, prevVal) } ee.on("update", handler); Ee. mit ('update ',' mental state of front-end watermelon before work ',' mental state of front-end watermelon after work ') ee.off("update", handler); //The following error is reported // 'number' is not assignable to parameter of type 'string' ee.emit('update', 1, 2) // (val: number) => void' is not assignable to parameter of type '() => void ee.on('destroy', (val: number) => {})

EventEmitter supports accepting the interface of an object structure as a type parameter and specifying the function types corresponding to different keys.

Then when we call on, emit, and off, If the event name and function parameter do not match, compilation cannot pass

Code implementation:

 class EventEmitter<T extends Record<string | symbol, any>> {   private eventMap: Record<keyof T, Array<(...args: any[]) => void>> = {} as any; //Add the listener function of the corresponding event   on<K extends keyof T>(eventName: K, listener: T[K]) { if (!this.eventMap[eventName]) {   this.eventMap[eventName] = []; } this.eventMap[eventName].push(listener); return this;   } //Trigger Event   emit<K extends keyof T>(eventName: K, ...args: Parameters<T[K]>) { const listeners = this.eventMap[eventName]; if (!listeners || listeners.length === 0) return false; listeners.forEach((listener) => {   listener(...args); }); return true;   } //Cancel listening to the corresponding event   off<K extends keyof T>(eventName: K, listener: T[K]) { const listeners = this.eventMap[eventName]; if (listeners && listeners.length > 0) {   const index = listeners.indexOf(listener);   if (index > -1) { listeners.splice(index, 1);   } } return this;   } }

Readers can copy the above two pieces of code to TypeScript Playground for testing.

Explain briefly.

First is the type parameter at the beginning.

 class EventEmitter<T extends Record<string | symbol, any>> {   // }

The purpose of extends here is to limit the range of types and prevent providing an irregular type parameter.

Record is an advanced type that comes with TypeScript. It creates an object structure based on the passed in key and value (T is it later).

 Record<string | symbol, any> //Equivalent to {   [key: string | symbol]: any }

The original type of value should be (...args: any[]) => void Well, it's limited to functions. However, it cannot pass the type detection when it is not a non literal quantity type, so it has to be changed to any. (Pity father's Index signature for type 'string' is missing Error reporting)

Then comes eventMap, whose actual content is as follows:

 eventMap = {   event1: [ handler1, handler2 ],   event2: [ handler3, handler4 ] }

So the key needs to be the key of the passed in object type parameter.

The function does not need to specify a specific type, because it is private and cannot be accessed outside the class. Without too much type inference, it is more relaxed and set to any function type.

 private eventMap: Record<keyof T, Array<(...args: any[]) => void>> =   {} as any;

Here I use the object literal, readers can also consider using the Map data structure.

Then is the on method. First, eventName must be one of the keys of T. Because we need to infer an internal type variable like K, we need to add <K extends keyof T> , listener is the corresponding T[K]

 on<K extends keyof T>(eventName: K, listener: T[K]): this

The off method is the same.

Then comes the exit. The first eventName uses keyof T No problem. Later, you need to take the parameters of the handler as the remaining parameters.

 emit<K extends keyof T>(eventName: K, ...args: Parameters<T[K]>): boolean

Here we use the advanced parameters type provided in TS, which is used to get the parameters of the function and return an array type.

Temporary Extension Custom Event

If you want to temporarily add an event to an instance with a fixed type, you can use & Expand the cross type.

 interface Events {   update(newVal: string, prevVal: string): void;   destroy(): void; } const ee = new EventEmitter<Events>(); //Expand with& const ee2 = ee as EventEmitter<   Events & { customA(a: boolean): void;   } >; //No error reporting ee2.emit('customA', true) //Or (ee as EventEmitter<   Events & { customA(a: boolean): void;   } >).emit('customA', true)

ending

After some transformation, we made full use of TypeScript's powerful type gymnastics ability to build a type safe EventEmitter. Write the wrong event name, the function type is not correct, and there is no fear at all.

This type of gymnastics is relatively simple. If it is more complicated, the readability will be poor.

TypeScript's type programming syntax is really ugly, and its readability is poor. If you are not the author of the library, I do not recommend excessive use of type gymnastics. It is as powerful as regular, but also very complex.