Type-safe EventTarget subclasses in TypeScript
Tags: node, typescript, webdev
Published: 2023-01-27 20:40:28

## Problem

I was surprised when I found out that [TypeScript](https://www.typescriptlang.org/) does not have a type-safe [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) interface definition:

```ts
interface EventTarget {
  addEventListener(
    type: string,
    callback: EventListenerOrEventListenerObject | null,
    options?: AddEventListenerOptions | boolean
  ): void;
  
  dispatchEvent(event: Event): boolean;

  removeEventListener(
    type: string,
    callback: EventListenerOrEventListenerObject | null,
    options?: EventListenerOptions | boolean
  ): void;
}
```

This makes it difficult to create type-safe APIs that extend `EventTarget`. Take the following example:

```ts
// API definition
interface MediaPlayerEvent {
  readonly time: number;
}

class MediaPlayer extends EventTarget {
  play() {
    // ...
    this.dispatchMediaPlayerEvent("play", time);
  }

  stop() {
    // ...
    this.dispatchMediaPlayerEvent("stop", time);
  }

  private dispatchMediaPlayerEvent(type: string, time: number) {
    this.dispatchEvent(
      new CustomEvent(eventType, { detail: { time } });
    );
  }
}

// API consumer
const player = new MediaPlayer(/* ... */);
player.addEventListener("play", (e) => {
  // compilation error: `e` is of type `Event`
  const { time } = e.detail;
});
```

How can the API consumers know which events are available and corresponding event types from a TypeScript IDE?

## Solution

We can use the following type definitions to solve our problem:

```ts
type TypedEventTarget<EventMap extends object> =
  { new (): IntermediateEventTarget<EventMap>; };

// internal helper type
interface IntermediateEventTarget<EventMap> extends EventTarget {
  addEventListener<K extends keyof EventMap>(
    type: K,
    callback: (
      event: EventMap[K] extends Event ? EventMap[K] : never
    ) => EventMap[K] extends Event ? void : never,
    options?: boolean | AddEventListenerOptions
  ): void;

  addEventListener(
    type: string,
    callback: EventListenerOrEventListenerObject | null,
    options?: EventListenerOptions | boolean
  ): void;
}
```

Then when can re-define the `MediaPlayer` class as follows:

```ts
class MediaPlayer extends (EventTarget as TypedEventTarget<{
  play: CustomEvent<MediaPlayerEvent>;
  stop: CustomEvent<MediaPlayerEvent>;
}>) {
  // ...
}
```

Voilà! We have type safety:

```ts
player.addEventListener("play", (e) => {
  // it works now!
  const { time } = e.detail;
});

player.addEventListener("other", (e) => {
  // this also works, but `e` is of type: `Event`
});
```

## Limitations

All properties in the `EventMap` of `TypedEventTarget<EventMap>` must implement the [Event](https://developer.mozilla.org/en-US/docs/Web/API/Event) interface since `EventTarget` can only dispatch objects that implement it. In other words, this should not compile:

```ts
class MediaPlayer2 extends (EventTarget as TypedEventTarget<{
  play: MediaPlayerEvent;
  stop: MediaPlayerEvent;
}> {
  // ...
}
```

However [tsc](https://www.typescriptlang.org/docs/handbook/compiler-options.html) will not complain. This is mitigated by making it impossible to listen to such events:

```ts
const player = new MediaPlayer2(/* ... */);

player.addEventListener("play", e => {
  // compilation error:
  // `e` and return type are of type: `never`
});
```

We could prevent the `class` from compiling by replacing `unknown` with `Event` in the generic constraint (i.e. `EventMap extends Record<string, unknown>`), however this has the unfortunate side-effect of removing the event name suggestions in [Visual Studio Code IntelliSense](https://code.visualstudio.com/docs/editor/intellisense) so I opted for the latter.

Finally, it's worth noting that given the API design of `EventTarget`, there doesn't seem to be a way to add type-safety to event dispatching.

If you have a way to overcome any of these limitations, let me know in the comments section!

## Posts that inspired this one

This post was inspired by [RJ Zaworski's](https://rjzaworski.com/2021/06/event-target-with-typescript) and [James Garbutt's](https://dev.to/43081j/strongly-typed-event-emitters-using-eventtarget-in-typescript-3658) posts. Credits to them for the initial ideas! That being said, the solutions in those posts have some shortcomings addressed by the solution on this post. For example RJ's does not compile in [strict](https://www.typescriptlang.org/tsconfig#strict) mode and has the runtime overhead of a new class. On the other hand, James' does not prevent `EventMap` from containing non-`Event` fields.