Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 214
Rijk Avatar asked Jan 28 '20 00:01

Rijk


People also ask

Which is the correct method to add an event listener?

The addEventListener() method allows you to add event listeners on any HTML DOM object such as HTML elements, the HTML document, the window object, or other objects that support events, like the xmlHttpRequest object.

Which operator is used to attach an event handler to an event?

C# supports event handler assignment using: The += operator, which is also used in the common language runtime (CLR) event handling model.


2 Answers

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

like image 193
jcalz Avatar answered Sep 18 '22 15:09

jcalz


I came up with a solution that mostly works. It requires listing all EventMap types but after that it should catch all errors.


// List all `EventMap` types here.
type DOMEventMapDefinitions = [
  [HTMLElement, HTMLElementEventMap],
  [Document, DocumentEventMap],
  [Window, WindowEventMap],
  [FileReader, FileReaderEventMap],
  [Element, ElementEventMap],
  [Animation, AnimationEventMap],
  [EventSource, EventSourceEventMap],
  [AbortSignal, AbortSignalEventMap],
  [AbstractWorker, AbstractWorkerEventMap]
  // ...
];
type DOMEventSubscriber = DOMEventMapDefinitions[number][0];

type MapDefinitionToEventMap<D extends { [K: number]: any[] }, T> = { [K in keyof D]: D[K] extends any[] ? (T extends D[K][0] ? D[K][1] : never) : never };
type GetDOMEventMaps<T extends DOMEventSubscriber> = MapDefinitionToEventMap<DOMEventMapDefinitions, T>;

type MapEventMapsToKeys<D extends { [K: number]: any }> = { [K in keyof D]: D[K] extends never ? never : keyof D[K] };
type MapEventMapsToEvent<D extends { [K: number]: any }, T extends PropertyKey> = { [K in keyof D]: D[K] extends never ? never : (T extends keyof D[K] ? D[K][T] : never) };

// Works for all types listed in `DOMEventMapDefinitions` and any types that are assingable to those types.
function customAddEventListener<T extends DOMEventSubscriber, K extends MapEventMapsToKeys<GetDOMEventMaps<T>>[number] & string>(
  element: T,
  type: K,
  handler: (this: T, ev: (MapEventMapsToEvent<GetDOMEventMaps<T>, K>[number])) => any
) {
  element.addEventListener(type, handler as any);
}


//--------------------------------------------------------------------------
// Examples:


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

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


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

customAddEventListener(document.createElement("div"), "oopsie",
  (event: FocusNavigationEvent) => { console.log(event.originLeft); }
); // error! Argument of type '"oopsie"' is not assignable ...


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

customAddEventListener(document.createElement("div"), "blur",
  (event: FocusNavigationEvent) => { console.log(event.originLeft); }
); // error! 'FocusEvent' is not assignable to type 'FocusNavigationEvent'


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

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

Playground Link

Playground Link with more (all?) EventMap types

Playground Link with some optimizations for compile times

like image 37
Lej77 Avatar answered Sep 19 '22 15:09

Lej77