Reputation: 1670
I'm trying to migrate my project from a JavaScript to TypeScript, and I have a problem with migrating the class for handling events.
To avoid double describing options for an add/remove event listener, we use a wrapper like this:
constructor() {
this.windowResizeHandler = new MyEventHandler(
target: window,
event: 'resize',
handler: e => this.handleResize_(e),
options: {passive: true, capturing: true},
);
}
connectedCallback() {
this.windowResizeHandler.add();
}
disconnectedCallback() {
this.windowResizeHandler.remove();
}
Now I don't know how to write this in TypeScript without losing information about events typing. For example:
document.createElement('button').addEventListener('click', e => {
// Here e is MouseEvent.
});
But if I write my wrapper like:
interface EventHandlerParams {
readonly target: EventTarget;
readonly event: Event;
readonly listener: (e: Event) => void;
readonly params: AddEventListenerOptions;
}
export class EventHandler {
public constructor(params: EventHandlerParams) {}
}
Then I lose the typings:
new MyEventHandler(
target: document.createElement('button'),
event: 'click',
handler: e => { /* Here e is just Event not MouseEvent */ },
options: {passive: true, capturing: true},
);
Is there any options for me to use event typings from lib.dom.d.ts
here?
I've tried something like this:
interface Mapping {
[Window]: WindowEventMap;
[HTMLElement]: HTMLElementEventMap;
}
interface EventHandlerParams<TTarget extends keyof Mapping,
TEventName extends keyof Mapping[TTarget],
TEvent extends Mapping[TTarget][TEventName]> {
readonly event: TEventName;
readonly listener: (event: TEvent) => void;
readonly params?: AddEventListenerOptions;
readonly target: TTarget;
}
export class EventHandler<TTarget extends keyof Mapping,
TEventName extends keyof Mapping[TTarget],
TEvent extends Mapping[TTarget][TEventName]> {
public constructor(params: EventHandlerParams<TTarget, TEventName, TEvent>) {}
}
But I can't use this, because types can not be interface properties and there is no any other options to provide constraint for TTarget
.
Upvotes: 2
Views: 4341
Reputation: 11
I know this is a bit late, but i found myself into this question so i'm going to leave here what i found very usefull. There's a good article about this by James Garbutt: Using strongly typed events in TypeScript.
The short Answer would be: Extend one of the global EventMap
's. Typescript has various built-in maps that summarises common DOM events. I'll use WindowEventMap
as an example, but have in mind that there are others such as DocumentEventMap
or HTMLElementEventMap
.
//This is something i used in a small game.
interface AudioEventDetail {
action: 'play' | 'pause' | 'stop' | 'resume';
type: 'background' | 'ui';
audioId: number;
}
declare global {
interface WindowEventMap {
'audioEvent': CustomEvent<AudioEventDetail>;
//You can add multiple definitions here:
'tooltipEvent': CustomEvent<TooltipDetail>; //Like this.
}
}
Here I just extended the interface WindowEventMap
since the idea was to add a Listener like this:
window.addEventListener('audioEvent', (event: CustomEvent<AudioEventDetail>) => {
const { action, type, audio } = event.detail;
//Here you have strongly typed access to action, type and audio parameters.
});
To emit it you need to do it like this:
window.dispatchEvent(new CustomEvent('audioEvent', { detail: { action: 'play', type: 'background', audio: 0 } }));
The only thing i don't like about this approach is that, adding event listeners would be strongly typed, but when constructing them, you basically depend on the CustomEvent
constructor.
You can however, create a class
that acts as syntactic sugar for this purpose:
class AudioEvent extends CustomEvent<AudioEventDetail> {
constructor(detail: AudioEventDetail) {
super('audioEvent', { detail });
}
}
//So when extending WindowEventMap it would look like this:
declare global {
interface WindowEventMap {
'audioEvent': AudioEvent; //Here we use the class instead.
}
}
//When Adding Listeners you're using the same class to type the event itself.
window.addEventListener('audioEvent', (event: AudioEvent) => {
const { action, type, audio } = event.detail;
//Your code here...
});
//And when emmiting a new event you'll use a constructor with strongly typed event parameters.
window.dispatchEvent(new AudioEvent({ action: 'play', type: 'background', audio: 0 }));
Since we're using a constructor, you could directly ask for each parameter or set default values. You'll never loose those types again!
Hope it helps someone! :D
Upvotes: 1
Reputation: 249546
There is a type in lib.dom.ts
that contains mappings between all event names and event argument types. It's called WindowEventMap
.
So we can for example write the following:
interface EventHandlerParams<T extends keyof WindowEventMap> {
readonly target: EventTarget;
readonly event: T;
readonly options: AddEventListenerOptions;
readonly listener: (e: WindowEventMap[T]) => void
}
export class EventHandler<T extends keyof WindowEventMap> {
public constructor(params: EventHandlerParams<T>) { }
}
new EventHandler({
target: document.createElement('button'),
event: 'click',
options: { passive: true },
listener: e => { e.x /* e is MouseEvent */ }
});
EventHandlerParams
is now generic and will capture the event name as the type parameter T
. We also made EventHandler
generic, and it's T
will be determined by the prams passed to it. Armed with T
(which will contain the string literal type for the event) we can get access to the actual param type for the event from WindowEventMap
and use it in our listener signature.
Note I think before 3.0 the arguments to listener might not be inferred to the correct type (they might be inferred to any
). If you run into this issue let me know and I can provide the before 3.0 version.
Upvotes: 2