Strongly typed event emitters using EventTarget in TypeScript
Tags: typescript, javascript
Published: 2021-04-04 19:45:22

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

```ts
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
```

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:

```ts
interface EventTarget {
  // ...
  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject | null,
    options?: boolean | AddEventListenerOptions
  ): void;
}
```

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

```ts
// 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}>) => {
    // ...
  }
);
```

# A possible solution

The way I solved this is as follows:

```ts
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
});
```

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`:

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

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:

```ts
interface StateEventMap {
  'my-event': CustomEvent;
}
```

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:

```ts
addEventListener(
  type: 'my-event',
  listener: (ev: CustomEvent) => void,
  options?: boolean | AddEventListenerOptions
);
```

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:

```ts
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;
}
```

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:

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

class State extends typedEventTarget {
  // ...
}
```

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`:

```ts
class State extends EventTarget<StateEventMap> {
 // ...
}
```

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.