DEV Community

Marco Gonzalez
Marco Gonzalez

Posted on • Updated on

Type-safe EventTarget subclasses in TypeScript

Problem

I was surprised when I found out that TypeScript does not have a type-safe EventTarget interface definition:

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;
}
Enter fullscreen mode Exit fullscreen mode

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

// 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;
});
Enter fullscreen mode Exit fullscreen mode

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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

class MediaPlayer extends (EventTarget as TypedEventTarget<{
  play: CustomEvent<MediaPlayerEvent>;
  stop: CustomEvent<MediaPlayerEvent>;
}>) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Voilà! We have type safety:

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

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

Limitations

All properties in the EventMap of TypedEventTarget<EventMap> must implement the Event interface since EventTarget can only dispatch objects that implement it. In other words, this should not compile:

class MediaPlayer2 extends (EventTarget as TypedEventTarget<{
  play: MediaPlayerEvent;
  stop: MediaPlayerEvent;
}> {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

However tsc will not complain. This is mitigated by making it impossible to listen to such events:

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

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

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 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 and James Garbutt's 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 mode and has the runtime overhead of a new class. On the other hand, James' does not prevent EventMap from containing non-Event fields.

Top comments (0)