DEV Community

James Garbutt
James Garbutt

Posted on

Strongly typed event emitters using EventTarget in TypeScript

In case you weren't aware, you can have an event emitting class using only natively available APIs:

class State extends EventTarget {
  private __loading: boolean = false;

  public set loading(v: boolean) {
    this.__loading = v;
    this.dispatchEvent(new CustomEvent('loading-changed'));
  }

  public get loading(): boolean {
    return this.__loading;
  }
}

const state = new State();
state.addEventListener('loading-changed', () => {
  console.log(`LOG: loading = ${state.loading}`);
});

state.loading = true;
// LOG: loading = true
Enter fullscreen mode Exit fullscreen mode

Of course, this is a very rough example but should get the idea cross. You don't need an event emitter library or some other dependency, the browser already has one!

The problem

The problem with this in TypeScript is that EventTarget has weak event types:

interface EventTarget {
  // ...
  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject | null,
    options?: boolean | AddEventListenerOptions
  ): void;
}
Enter fullscreen mode Exit fullscreen mode

This means we can't have any nice intellisense on valid events and their types:

// somewhere...
state.dispatchEvent(new CustomEvent<{x: number}>(
  'my-event',
  {
    detail: {
      x: 5
    }
  }
);

// elsewhere...
state.addEventListener(
  'my-event',
  // Following line will error because it must
  // be Event, rather than our custom event.
  (ev: CustomEvent<{x: number}>) => {
    // ...
  }
);
Enter fullscreen mode Exit fullscreen mode

A possible solution

The way I solved this is as follows:

interface StateEventMap {
  'my-event': CustomEvent<{x: number}>;
}

interface StateEventTarget extends EventTarget {
  addEventListener<K extends keyof StateEventMap>(
    type: K,
    listener: (ev: StateEventMap[K]) => void,
    options?: boolean | AddEventListenerOptions
  ): void;
  addEventListener(
    type: string,
    callback: EventListenerOrEventListenerObject | null,
    options?: EventListenerOptions | boolean
  ): void;
}

const typedEventTarget = EventTarget as {new(): StateEventTarget; prototype: StateEventTarget};

class State extends typedEventTarget {
  // ...
}

const s = new State();

s.addEventListener('my-event', (ev) => {
  ev.detail.x; // WORKS! strongly typed event
});
Enter fullscreen mode Exit fullscreen mode

Again, this isn't the perfect solution but it works until we have a better, easier one.

Explanation

For those uninterested in why this works, please do skip ahead!

To start, let's take a look at our addEventListener:

addEventListener<K extends keyof StateEventMap>(
  type: K,
  listener: (ev: StateEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions
): void;
Enter fullscreen mode Exit fullscreen mode

Here we are telling TypeScript this method can only be called with a type which exists as a key of StateEventMap.

We can define StateEventMap like so:

interface StateEventMap {
  'my-event': CustomEvent;
}
Enter fullscreen mode Exit fullscreen mode

This would mean keyof StateEventMap is 'my-event'. It would be a union of strings if we had more keys.

Similarly, we are then defining that the listener must consume the value which exists at the specified key. In this case, StateEventMap['my-event'] is CustomEvent, so we're effectively stating:

addEventListener(
  type: 'my-event',
  listener: (ev: CustomEvent) => void,
  options?: boolean | AddEventListenerOptions
);
Enter fullscreen mode Exit fullscreen mode

Keep in mind, you could actually define overloads this way too instead of using generics (one signature per event).

Now because EventTarget is an interface in TypeScript, we can extend it and add our strongly typed methods:

interface StateEventTarget extends EventTarget {
  addEventListener<K extends keyof StateEventMap>(
    type: K,
    listener: (ev: StateEventMap[K]) => void,
    options?: boolean | AddEventListenerOptions
  ): void;
  addEventListener(
    type: string,
    callback: EventListenerOrEventListenerObject | null,
    options?: EventListenerOptions | boolean
  ): void;
}
Enter fullscreen mode Exit fullscreen mode

Note that we still keep the string overload in case there are other events we haven't mapped, and to implement the base interface correctly.

Finally, the true hackery here that I couldn't find a way to avoid is the cast:

const typedEventTarget = EventTarget as {new(): StateEventTarget; prototype: StateEventTarget};

class State extends typedEventTarget {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

We are essentially casting the EventTarget class (not the interface) as our strongly typed version. We then extend this instead of directly extending EventTarget. Remember, it is the same object, though.

Ideal solution

Admittedly, the solution here is not ideal and slightly hacky. The ideal solution, in my opinion, is that TypeScript introduces a generic version of EventTarget:

class State extends EventTarget<StateEventMap> {
 // ...
}
Enter fullscreen mode Exit fullscreen mode

Something like this would be incredibly useful. One can hope :D

Wrap-up

Even if you don't use typescript, or don't want these strong types, I would recommend you give web APIs like EventTarget a try.

Top comments (1)

Collapse
 
marcogrcr profile image
Marco Gonzalez

Thanks for sharing! What started as a comment, ended up as a post to address some shortcoming of this solution. See here.