2

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.

2 Answers 2

2

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.

Sign up to request clarification or add additional context in comments.

5 Comments

Thx for so quick reply. I've thought about this solution but I will have to provide several dozen of EventHandlerParams interfaces for each *EventMap inteface. Like interface EventHandlerWindowParams<T extends keyof WindowEventMap>, interface EventHandlerHtmlElementParams<T extends keyof HTMLElementEventMap> and so on.
@Yavanosta So you would like based on the type of the target to get the apropriate events ?
I'm looking for something like this: interface EventHandlerHtmlElementParams<TTarget extends EventTaget, TEvent> { target: TTarget, event: TEvent extends key of SomeMagicMapping[TTarget]; listener(event: SomeMagicMapping[TTarget][TEvent]) }. But seems there is no reusable link between, for example, HTMLElement and HTMLElementEventMap
@Yavanosta We can extract a map of all event names a type can take. type EventKeys<T extends EventTarget> = T extends { addEventListener: { (type: infer K, listener: any): void; (type: string, listener: EventListenerOrEventListenerObject): void; } } ? K: never; But there is no way back to the original event map they came from.. We could have an intersection of all event maps and index into that.. but this exhaustive intersection would have to be manually maintained and two events with the same name might have different parameter types
But WindowEventMap is not exported and not declared in lib.dom.ts =(
1

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

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.