EventTarget with TypeScript

The EventTarget DOM interface is used by a variety of browser APIs to dispatch events and attach listeners. It isn’t prescriptive about the types of events that an implementation can send, however, as reflected by rather general types:

interface EventTarget {
  dispatchEvent(e: Event): boolean

  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | EventListenerOptions,
  ): void

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

In other words, a program interacting with an EventTarget can dispatch and listen for pretty much anything (whether the implementation supports it or not). That’s a reasonable default for JavaScript, but with a type system in the mix we can make dealing with the EventTarget a little safer.

Adding TypeScript to an EventTarget

An EventTarget comes with a dictionary of events. Using TypeScript a simple dictionary might look something like this:

type ReadyEvent = { readonly type: 'ready' };

type DataEvent = {
  readonly type: 'data';
  readonly data: any;
};

type MyEvent = ReadyEvent | DataEvent;

Implementing a pure TypeScript event emitter around this dictionary like this is straightforward enough, but if when interacting with DOM APIs the dictionary could also fit into a narrower, typesafe EventTarget.

Here’s a minimal example, omitting some of the option-handling for brevity:

class MyEventTarget extends EventTarget {
  public dispatchEvent(e: Event & MyEvent): boolean {
    return super.dispatchEvent(e);
  }

  public dispatch(e: MyEvent): boolean {
    return this.dispatchEvent(Object.assign(new Event(e.type), e));
  }

  public addEventListener<
    T extends MyEvent['type'],
    E extends MyEvent & { type: T }
  >(type: T, listener: ((e: Event & E) => boolean) | null) {
    super.addEventListener(type, listener);
  }

  public removeEventListener(type: MyEvent['type']) {
    super.removeEventListener(type, null)
  }
}

Except for the nice-but-not-strictly-necessary convenience of the dispatch helper, this is a faithful (if bare bones) implementation of the EventTarget interface. By imposing tighter constraints on the base class, however, the type checker will now ensure that MyEventTarget is being used appropriately at compile time.

Consider cases like:

target.addEventListener('ready', function (event) {
  // `event.message` is undefined (listener is bound to `ReadyEvent`)
  // @ts-expect-error
  console.log(event.message);
  return false;
});

// doesn't implement `MyEvent`
// @ts-expect-error
target.dispatchEvent(new Event('some-event'));

// the `'foobar'` event isn't defined
// @ts-expect-error
target.dispatch({ type: 'foobar' });

// missing `.data` property
// @ts-expect-error
target.dispatch({ type: 'data' });

That’s a big improvement over the “anything goes” jungle of the underlying DOM API! But a little bit of cleanup can make it even better.

Making it generic

Rather than creating bespoke subclasses of EventTarget for every purpose, consider a generic version that just needs an event dictionary plugged in:

class TypedEventTarget<EventDef extends { type: any }> extends EventTarget {
  public dispatchEvent(e: Event & EventDef): boolean {
    return super.dispatchEvent(e);
  }

  public dispatch(e: EventDef): boolean {
    const event = Object.assign(new Event(e.type), e);
    return this.dispatchEvent(event);
  }

  public addEventListener<
    T extends EventDef['type'],
    E extends EventDef & { type: T }
  >(type: T, listener: ((e: Event & EventDef) => boolean) | null) {
    super.addEventListener(type, listener);
  }

  public removeEventListener(type: EventDef['type']) {
    super.removeEventListener(type, null)
  }
}

Enlisting this new TypedEventTarget, the original example is almost trivial to implement–while still keeping all of the same typesafe goodness:

class MyEventTarget extends TypedEventTarget<MyEvent> {}

const target = new MyEventTarget();

Not too bad. And guaranteed better at runtime!