-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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
- Suggestion: Add the nameof compile-time operator to convert property and function names into strings microsoft/TypeScript#1579
- Allow type-level extraction of a class name as a string literal microsoft/TypeScript#43325
Tasks
- Integrate the above to
Evented
- Test that default behaviour still works
- Test ways of having succinct was of expressing this
- Integrate this to
js-async-init
before enabling this feature - Test that we can directly add typed events without bothering with
@ts-ignore
.