I'm updating my shorter-js codebase with proper JSDoc to generate TypeScript definitions but I'm unable to narrow down this one.

I have the on() snippet which makes use of native Element.addEventListener, so far so good, the problem is when using TouchEvent as a parameter for an actual handler, TypeScript throws a 4 line error, see below snippet.

/**
 * @param {HTMLElement | Element | Document} element event.target
 * @param {string} eventName event.type
 * @param {EventListener} handler callback
 * @param {EventListenerOptions | boolean | undefined} options other event options
 */
function on(element, eventName, handler, options) {
  const ops = options || false;
  element.addEventListener(eventName, handler, ops);
}

/**
 * test handler
 * @type {EventListener}
 * @param {TouchEvent} e
 */
function touchdownHandler(e){
  console.log(e.touches)
}

// test invocation
on(document.body, 'touchdown', touchdownHandler);
body {height: 100%}

The on(document.body, 'touchdown', touchdownHandler) invocation throws this 4 line error:

Argument of type '(e: TouchEvent) => void' is not assignable to parameter of type 'EventListener'.
  Type '(e: TouchEvent) => void' is not assignable to type 'EventListener'.
    Types of parameters 'e' and 'evt' are incompatible.
      Type 'Event' is missing the following properties from type 'TouchEvent': altKey, changedTouches, ctrlKey, metaKey, and 7 more.

In fact I get the exact same when using document.body.addEventListener(...) invocation. So I've tried various definitions within my index.d.ts, but nothing to resolve this.

To my knowledge I think I need to define something in my index.d.ts, then use it in the JSDoc for the touchdownHandler.

Any suggestions?


Solution 1:

Problem here is that you need to provide addEventListener with actual type of event, so it would map handler to accept this particular kind of event. dom.d.ts declaration (DOM Library) contain event maps for this particular usage. Your goal is to ensure that eventName is mapped to event type of handler.

While in TS we can use param types so it can be done without generics, you can't do same with JSDoc, so we have to introduce template variable for Event Type:

/**
 * @template {keyof HTMLElementEventMap} T
 * @param {HTMLElement} element
 * @param {T} eventName
*/

There are other event maps, but in most cases this will be enough. If not - check dom.d.ts source code.

Next we need to type out handler:

/**
 * @template {keyof HTMLElementEventMap} T
 * @param {HTMLElement} element
 * @param {T} eventName
 * @param {(event: HTMLElementEventMap[T]) => void} handler
 */

In this case, if T will be 'click', we will get event in handler as HTMLElementEventMap['click'], which is MouseEvent.

And last - options. In JSDoc you can mark parameter as optional instead of specifying undefined:

/**
 * @template {keyof HTMLElementEventMap} T
 * @param {HTMLElement} element
 * @param {T} eventName
 * @param {(event: HTMLElementEventMap[T]) => void} handler
 * @param {EventListenerOptions | boolean} [options]
 */

This will work as you expect.

Full Code:

/**
 * @template {keyof HTMLElementEventMap} T
 * @param {HTMLElement} element
 * @param {T} eventName
 * @param {(event: HTMLElementEventMap[T]) => void} handler
 * @param {EventListenerOptions | boolean} [options]
 */
function on(element, eventName,  handler, options) {
  const ops = options || false;
  element.addEventListener(eventName, handler, ops);
}

/**
 * test handler
 * @param {TouchEvent} e
 */
function touchdownHandler(e) {
  console.log(e.touches)
}

// test invocation
on(document.body, 'touchend', touchdownHandler);

Sandbox

To declare this in you have to use Generics:

declare function on<T extends keyof HTMLElementEventMap>(element: HTMLElement, eventName: T, handler: (event: HTMLElementEventMap[T]) => void, options?: EventListenerOptions | boolean): void;