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;
}
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;
});
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;
}
Then when can re-define the MediaPlayer
class as follows:
class MediaPlayer extends (EventTarget as TypedEventTarget<{
play: CustomEvent<MediaPlayerEvent>;
stop: CustomEvent<MediaPlayerEvent>;
}>) {
// ...
}
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`
});
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;
}> {
// ...
}
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`
});
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)