In case you weren't aware, you can have an event emitting class using only natively available APIs:
class State extends EventTarget {
private __loading: boolean = false;
public set loading(v: boolean) {
this.__loading = v;
this.dispatchEvent(new CustomEvent('loading-changed'));
}
public get loading(): boolean {
return this.__loading;
}
}
const state = new State();
state.addEventListener('loading-changed', () => {
console.log(`LOG: loading = ${state.loading}`);
});
state.loading = true;
// LOG: loading = true
Of course, this is a very rough example but should get the idea cross. You don't need an event emitter library or some other dependency, the browser already has one!
The problem
The problem with this in TypeScript is that EventTarget
has weak event types:
interface EventTarget {
// ...
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions
): void;
}
This means we can't have any nice intellisense on valid events and their types:
// somewhere...
state.dispatchEvent(new CustomEvent<{x: number}>(
'my-event',
{
detail: {
x: 5
}
}
);
// elsewhere...
state.addEventListener(
'my-event',
// Following line will error because it must
// be Event, rather than our custom event.
(ev: CustomEvent<{x: number}>) => {
// ...
}
);
A possible solution
The way I solved this is as follows:
interface StateEventMap {
'my-event': CustomEvent<{x: number}>;
}
interface StateEventTarget extends EventTarget {
addEventListener<K extends keyof StateEventMap>(
type: K,
listener: (ev: StateEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
): void;
}
const typedEventTarget = EventTarget as {new(): StateEventTarget; prototype: StateEventTarget};
class State extends typedEventTarget {
// ...
}
const s = new State();
s.addEventListener('my-event', (ev) => {
ev.detail.x; // WORKS! strongly typed event
});
Again, this isn't the perfect solution but it works until we have a better, easier one.
Explanation
For those uninterested in why this works, please do skip ahead!
To start, let's take a look at our addEventListener
:
addEventListener<K extends keyof StateEventMap>(
type: K,
listener: (ev: StateEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
Here we are telling TypeScript this method can only be called with a type
which exists as a key of StateEventMap
.
We can define StateEventMap
like so:
interface StateEventMap {
'my-event': CustomEvent;
}
This would mean keyof StateEventMap
is 'my-event'
. It would be a union of strings if we had more keys.
Similarly, we are then defining that the listener must consume the value which exists at the specified key. In this case, StateEventMap['my-event']
is CustomEvent
, so we're effectively stating:
addEventListener(
type: 'my-event',
listener: (ev: CustomEvent) => void,
options?: boolean | AddEventListenerOptions
);
Keep in mind, you could actually define overloads this way too instead of using generics (one signature per event).
Now because EventTarget
is an interface in TypeScript, we can extend it and add our strongly typed methods:
interface StateEventTarget extends EventTarget {
addEventListener<K extends keyof StateEventMap>(
type: K,
listener: (ev: StateEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
): void;
}
Note that we still keep the string
overload in case there are other events we haven't mapped, and to implement the base interface correctly.
Finally, the true hackery here that I couldn't find a way to avoid is the cast:
const typedEventTarget = EventTarget as {new(): StateEventTarget; prototype: StateEventTarget};
class State extends typedEventTarget {
// ...
}
We are essentially casting the EventTarget
class (not the interface) as our strongly typed version. We then extend this instead of directly extending EventTarget
. Remember, it is the same object, though.
Ideal solution
Admittedly, the solution here is not ideal and slightly hacky. The ideal solution, in my opinion, is that TypeScript introduces a generic version of EventTarget
:
class State extends EventTarget<StateEventMap> {
// ...
}
Something like this would be incredibly useful. One can hope :D
Wrap-up
Even if you don't use typescript, or don't want these strong types, I would recommend you give web APIs like EventTarget
a try.
Top comments (1)
Thanks for sharing! What started as a comment, ended up as a post to address some shortcoming of this solution. See here.