dhuCerbin
dhuCerbin

Reputation: 249

Generic parameter extends type but is in contravariant position and typescript can't unify it

I try to allow my function consumers to user wider type but I use this type in contravariant position and typescript complaints.

Simplified code:

function wrapper<T extends Event = Event>(node: HTMLElement) {
    const handler = (e: T) => console.log(e.cancelable);
    node.addEventListener("click", handler);
}

And I get error:

Type 'Event' is not assignable to type 'T'. 'Event' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Event'.

How can I express my intent so typescript would understand me?

Link to working playground

Full code:

export function tracker(
  eventType: string,
  options?: boolean | AddEventListenerOptions
) {
  const stream: Stream<Event> = Stream.empty();

  function tracker(node: HTMLElement) {
    const handler = (event: Event) => stream.shamefullySendNext(event);
    node.addEventListener( eventType, handler, options);

    return {
      destroy: () => {
        node.removeEventListener(eventType, handler);
        stream.shamefullySendComplete();
      },
    };
  }

  return {
    stream,
    tracker,
  };
}

Upvotes: 0

Views: 479

Answers (1)

Linda Paiste
Linda Paiste

Reputation: 42170

Your question is quite confusing, but I believe that what you want is to refine based on the event type (ie. "click") rather than the event.

We cannot ever safely use a generic T that extends Event because this T could have any arbitrary additional properties. Consider the following example:

interface MyEvent extends Event {
 customProperty: string
};

const handler = (e: MyEvent) => console.log(e.cancelable);

No event listener would ever be able to execute the callback handler because their event lacks the property customProperty. That's why you get the error that "Type 'Event' is not assignable to type 'T'."

We can however refine on event type because this is what addEventListener does.

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

addEventListener can be called with a generic specifying the event type name. When that generic K is set, the type of the event that is passed as an argument to the callback is refined based on the HTMLElementEventMap.

We can make your tracker function depend on the same generic <K extends keyof HTMLElementEventMap>. Now your eventType must be K and your handler must take (event: HTMLElementEventMap[K]). Notice that we have narrowed the event, but we haven't allowed for extra properties to be added via extends.

export function tracker<K extends keyof HTMLElementEventMap>(
  eventType: K,
  options?: boolean | AddEventListenerOptions
) {
  const stream: Stream<HTMLElementEventMap[K]> = Stream.empty();

  function tracker(node: HTMLElement) {
    const handler = (event: HTMLElementEventMap[K]) => stream.shamefullySendNext(event);
    node.addEventListener<K>( eventType, handler, options);

    return {
      destroy: () => {
        node.removeEventListener(eventType, handler);
        stream.shamefullySendComplete();
      },
    };
  }

  return {
    stream,
    tracker,
  };
}

Typescript Playground Link

Upvotes: 1

Related Questions