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
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.
C# supports event handler assignment using: The += operator, which is also used in the common language runtime (CLR) event handling model.
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 MouseEvent
s, I can't use it as a function that accepts all Event
s. 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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With