Skip to content

Typed Events Map #12

@CMCDragonkai

Description

@CMCDragonkai

Specification

Sometimes we get some weird type error about the fact that we cannot specify a more specific event type in our event handlers because event target isn't typed and thus allows any event to be dispatched to any event name.

To get around this we can extend our Evented interface to actually be typesafe and to receive an optional EventMap that would allow downstream classes to specify a typed map of events that is being used.

This can be propagated to the js-async-init too, and it would be possible to do things like:

interface X extends StartStop<EventMap, StartReturn, StopReturn> {}
@StartStop({
  eventStart,
  eventStarted,
  eventStop,
  eventStopped
})
class X {
}

It would be a breaking change on the types, but we don't really use the StartReturn and StopReturn types much. And actually all the generic types would be optional here. The EventMap can be first since it is just likely to be used more.

Anyway this is what we would do to Evented to achieve this:

type EventListenerOrEventListenerObject<T extends Event> = ((evt: T) => void) | { handleEvent(evt: T): void };

class TypedEventTarget<E extends Record<string, Event> = Record<string, Event>> {
  private target: EventTarget = new EventTarget();

  // General case: any string maps to an Event
  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject<Event>,
    options?: boolean | AddEventListenerOptions
  ): void;

  // Specific case: string is a key in E
  addEventListener<K extends keyof E>(
    type: K,
    listener: EventListenerOrEventListenerObject<E[K]>,
    options?: boolean | AddEventListenerOptions
  ): void;

  // Implementation for addEventListener
  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject<Event>,
    options?: boolean | AddEventListenerOptions
  ) {
    this.target.addEventListener(type, listener as EventListenerOrEventListenerObject<Event>, options);
  }

  // General case: any string maps to an Event
  removeEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject<Event>,
    options?: boolean | EventListenerOptions
  ): void;

  // Specific case: string is a key in E
  removeEventListener<K extends keyof E>(
    type: K,
    listener: EventListenerOrEventListenerObject<E[K]>,
    options?: boolean | EventListenerOptions
  ): void;

  // Implementation for removeEventListener
  removeEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject<Event>,
    options?: boolean | EventListenerOptions
  ) {
    this.target.removeEventListener(type, listener as EventListenerOrEventListenerObject<Event>, options);
  }

  dispatchEvent<K extends keyof E>(type: K, event: E[K]) {
    this.target.dispatchEvent(event as Event);
  }
}

The above is pseudo code generated by chatgpt. What's cool is that it preserves the ability to not bother specifying specific types if you don't want to. You can still get the original behaviour of event map not caring about the type of the event. When you do care, you give it a much more specific type.

The relevant type map can then look like this:

interface MyEvents {
  'myEvent': MyEvent;
  'anotherEvent': AnotherEvent;
}

One might be careful that EventQUICConnectionStart.name is of type string and not the literal type of EventQUICConnectionStart.name. So I'm not sure how TS will end up inferring it or not. If not, then you have to do addEventListener<'EventQUICConnectionStart'>(...).

A little more boilerplate.

Additional Context

Tasks

  1. Integrate the above to Evented
  2. Test that default behaviour still works
  3. Test ways of having succinct was of expressing this
  4. Integrate this to js-async-init before enabling this feature
  5. Test that we can directly add typed events without bothering with @ts-ignore.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions