Using strongly typed events in TypeScript
Recently I have been working on improving a whole bunch of web components in one of our code bases. Part of this has been to improve the types and their strength (which also means better editor support :D).
These are just a few tips from what I found.
The aim
It is common that we might want to emit custom events from our components. For example:
class MyElement extends HTMLElement {
// ..
protected _doSomething(): void {
this.dispatchEvent(new CustomEvent('my-event'));
}
}
In these situations, a consumer of the component would listen like so:
node.addEventListener('my-event', (ev) => {
// ...
});
However, there would be no helpful type information to suggest that
my-event
is a valid (“known”) event and the type of the event it is.
So our aim to strengthen this would be to strongly type those events and give editors better completion/information.
Event maps
Thankfully, TypeScript already helped solve this problem with “event maps”. These are basically interfaces to hold the names of events and their type.
You can see that addEventListener
is roughly typed like so:
// example map
interface EventMap {
'my-event': MyCustomEventType;
}
// method
addEventListener<T extends keyof EventMap>(
type: T,
listener: (event: EventMap[T]) => any
);
Where EventMap
is one of a few available maps depending on the type of node
you’re dealing with.
The fall back, for when it isn’t in the event map, is basically Event
. This
means we’re not too strict.
Anyhow this results in useful hints and types like so:
document.addEventListener('load', fn); // knows that 'load' is an event
node.addEventListener('blur', (ev) => { ... }); // knows `ev` is `FocusEvent`
node.addEventListener('doesntexist', (ev) => { ... }); // `ev` is `Event`
Built in event maps
There are several, a couple are:
DocumentEventMap
- defines all events available toDocument
WindowEventMap
- defines all events available toWindow
HTMLElementEventMap
- defines all events available to any HTML element
Extending a built in map
You can extend a built in event map by augmenting the global interface:
declare global {
interface WindowEventMap {
'my-event': CustomEvent<{foo: number}>;
}
}
// ...
window.addEventListener(
'my-event', // will be a known event name, suggested in your editor
(ev) => { // will be typed as `CustomEvent<{foo: number}>`
ev.detail.foo; // number
}
);
Defining your own event maps
You may want to tie events to your component and only your component, rather than the globally available list of events.
To do this, you can re-define addEventListener
:
interface MyEventMap {
'my-event': CustomEvent<{foo: number}>;
}
class MyElement extends HTMLElement {
public addEventListener<T extends keyof MyEventMap>(
// the event name, a key of MyEventMap
type: T,
// the listener, using a value of MyEventMap
listener: (this: MyElement, ev: MyEventMap[T]) => any,
// any options
options?: boolean | AddEventListenerOptions
): void;
// the fallback for any event names not in our map
public addEventListener(
type: string,
listener: (this: MyElement, ev: Event) => any,
options?: boolean | AddEventListenerOptions
): void {
super.addEventListener(type, listener, options);
}
}
// ...
const node = document.createElement('my-element');
node.addEventListener(
'my-event', // strongly typed, suggested by editor
(ev) => { ... } // ev is a `CustomEvent<{foo: number}>`
);
It looks like there’s a lot going on here, but really we are just copying
what the original definition of addEventListener
is from TypeScript’s
DOM definitions.
Wrap up
Again, this was just a quick one to show the findings. These things can make the dev experience super nice, though.
Now in our editors we can get strongly typed event names along with their strongly typed events, rather than relying on casts and what not.