MatrixAI / js-events

Events for push-flow abstractions
https://polykey.com
Apache License 2.0
0 stars 0 forks source link

Typed Events Map #12

Open CMCDragonkai opened 1 year ago

CMCDragonkai commented 1 year ago

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.
CMCDragonkai commented 1 year ago

This is what it looks like when we have to add specific event types:

  quicServer.addEventListener(
    events.EventQUICServerConnection.name,
    // @ts-ignore
    (evt: events.EventQUICServerConnection) => {
      const connection = evt.detail;
      connection.addEventListener(
        events.EventQUICConnectionStream.name,
        // @ts-ignore
        async (evt: events.EventQUICConnectionStream) => {
          const stream = evt.detail;
          // Graceful close of writable
          process.stderr.write('>>>>>>>>> HANDLING THE QUIC SERVER STREAM\n');
          await stream.writable.close();
          // Consume until graceful close of readable
          for await (const _ of stream.readable) {
            // Do nothing, only consume
          }
          process.stderr.write('<<<<<<<< HANDLED THE QUIC SERVER STREAM\n');
        }
      );
    }
  );

It doesn't happen when those event handlers are defined elsewhere, but this can be annoying. If the Class.name could be made into literal types would be even easier to do...

CMCDragonkai commented 1 year ago

This would be useful https://github.com/microsoft/TypeScript/issues/1579.