Yavanosta
Yavanosta

Reputation: 1670

How can I create EventHandler class with typescript?

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

Answers (2)

Denis Vargas Rivero
Denis Vargas Rivero

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

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

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

Related Questions