javascripttypescriptaddeventlistenertypescript-typings

How to properly type event handler in wrapped addEventListener call in TypeScript


I'm trying to write an abstraction layer on the addEventListener method, but am running into a typing issue. In the minimal reproduction below;

function addEventListener<T extends EventTarget>(
  element: T,
  type: string,
  handler: EventListener
) {
  element.addEventListener(type, handler);
}

addEventListener<Window>(window, "mousedown", (event: MouseEvent) => {
  console.log(event.pageX);
});

TypeScript complains that the parameter type is incompatible with MouseEvent:

Argument of type '(event: MouseEvent) => void' is not assignable to parameter of type 'EventListener'.
  Types of parameters 'event' and 'evt' are incompatible.
    Type 'Event' is missing the following properties from type 'MouseEvent': altKey, button, buttons, clientX, and 20 more.

I know that the following (basic) event listener:

window.addEventListener('mousedown', (event: MouseEvent) => {
  console.log(event.pageX);
});

works just fine, so I'm assuming something is wrong in typing the handler parameter as EventListener, but I can't figure out what it should be.. Most answers I came across seem specific to React, which is not relevant in my case.

Above codesnippet on: TypeScript Playground | CodeSandbox


Solution

  • The problem is that your addEventListener() typing has no idea how the type parameter relates to the subtype of Event that handler should accept. In the TypeScript standard library file lib.dom.d.ts, individual EventTarget types are given a very specific mapping from type to handler. For example, the Window interface's signature for its addEventListener method looks like this:

    addEventListener<K extends keyof WindowEventMap>(
      type: K, 
      listener: (this: Window, ev: WindowEventMap[K]) => any, 
      options?: boolean | AddEventListenerOptions
    ): void;
    

    where WindowEventMap is defined here as a large mapping between type names and Event type. For the mousedown key specifically, the property value type is MouseEvent. Thus when you call

    window.addEventListener('mousedown', (event: MouseEvent) => {
      console.log(event.pageX);
    });
    

    everything works because K is inferred to be "mousedown" and thus the handler parameter expects a MouseEvent.


    Without that sort of mapping information, the typings tend to look like type is string and listener is (event: Event)=>void. But, as you've seen, you can't simply pass a (event: MouseEvent)=>void to a parameter expecting a (event: Event)=>void, because it's not safe in general to do this. If you give me a function that only accepts MouseEvents, I can't use it as a function that accepts all Events. If I try, and the function you give me accesses something like the pageX property, I might get a runtime error.

    This sort of restriction on function arguments was added in TypeScript 2.6 as a --strictFunctionTypes compiler option. If you turn that compiler option off your error should disappear, but I don't recommend you do that.

    Instead, if you want to loosen the typing just for your addEventListener function, you could make it generic in the Event subtype, like this:

    function addEventListener<T extends EventTarget, E extends Event>(
      element: T, type: string, handler: (this: T, evt: E) => void) {
      element.addEventListener(type, handler as (evt: Event) => void);
    }
    

    Then your code will compile:

    addEventListener(window, "mousedown", (event: MouseEvent) => {
      console.log(event.pageX);
    });
    

    But remember: this typing is too loose to catch errors, such as this:

    addEventListener(document.createElement("div"), "oopsie",
      (event: FocusNavigationEvent) => { console.log(event.originLeft); }
    );
    

    If you were to try that directly on an HTMLDivElement you'd get an error:

    document.createElement("div").addEventListener("oopsie",
      (event: FocusNavigationEvent) => { console.log(event.originLeft); }
    ); // error! oopsie is not acceptable
    

    Which wouldn't be resolved until you fixed both the type parameter

    document.createElement("div").addEventListener("blur",
      (event: FocusNavigationEvent) => { console.log(event.originLeft); }
    ); // error! FocusNavigationEvent is not a FocusEvent
    

    And used the matching Event subtype

    document.createElement("div").addEventListener("blur",
      (event: FocusEvent) => { console.log(event.relatedTarget); }
    ); // okay now
    

    So, that's about as far as I can go here. You could try to make an enormous mapping from all known EventTarget types to all the correct type parameters for each target, and then to all the correct Event subtypes for each EventTarget/type pair, and have a single generic addEventListener function that works... but it would be comparable in size to the lib.dom.d.ts file, and I'm not inclined to spend too much more time on this. If you need type safety you would probably be better off not using this particular abstraction. Otherwise, if you're okay with the compiler missing some mismatches, then the above typing could be a way to go.


    Okay, hope that helps; good luck!

    Playground link