Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type to retrieve valid events of an EventTarget #33047

Open
5 tasks done
kripod opened this issue Aug 23, 2019 · 6 comments
Open
5 tasks done

Add type to retrieve valid events of an EventTarget #33047

kripod opened this issue Aug 23, 2019 · 6 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@kripod
Copy link

kripod commented Aug 23, 2019

Search Terms

EventListener, EventMap, EventTarget

Suggestion

There should a type to retrieve valid event names and listeners for a given EventTarget. Window has the following method:

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

Every EventTarget should have its own corresponding EventMap (even if no special event types are available for a given target, thus, resulting in an empty object). The EventMap of a given EventTarget could be retrieved as follows:

type WindowEventMap = EventMap<Window>;
type DocumentEventMap = EventMap<Document>;
type AudioNodeEventMap = EventMap<AudioNode>; // Empty interface
// ...

Use Cases

I tried creating a function which encapsulates an event for easier lifecycle management with React Hooks:

export function managedEventListener<T extends EventTarget, K extends string>(
  target: T,
  type: K,
  callback: EventListener,
  options?: AddEventListenerOptions,
) {
  target.addEventListener(type, callback, options);
  return () => {
    target.removeEventListener(type, callback, options);
  };
}

Unfortunately, EventListener gives no proper IntelliSense and I had to use the as EventListener syntax like below, as suggested in #28357:

useEffect(
  () =>
    managedEventListener(window, 'deviceorientation', ((
      event: DeviceOrientationEvent,
    ) => {
      setOrientation(event);
    }) as EventListener),
  [],
);

My goal was to simplify the syntax to the following, with proper type inference:

useEffect(
  () =>
    managedEventListener(window, 'deviceorientation', event => {
      setOrientation(event);
    }),
  [],
);

Using conditional types, I was able to achieve the syntax above by replacing EventListener with a specialized EventListenerCallback type which extracts values from the 2 most commonly used event maps, namely WindowEventMap and DocumentEventMap:

type ExtractFrom<T, K> = K extends keyof T ? T[K] : never;
export type EventListenerCallback<T, K> = T extends Window
  ? (event: ExtractFrom<WindowEventMap, K>) => any
  : (T extends Document
      ? (event: ExtractFrom<DocumentEventMap, K>) => any
      : EventListener);

The new code for managedEventListener was born:

export function managedEventListener<T extends EventTarget, K extends string>(
  target: T,
  type: K,
  callback: EventListenerCallback<T, K>,
  options?: AddEventListenerOptions,
) {
  target.addEventListener(type, callback, options);
  return () => {
    target.removeEventListener(type, callback, options);
  };
}

Examples

The code above could be greatly simplified by introducing the aforementioned EventMap<EventTarget> type:

export function managedEventListener<T extends EventTarget, K extends keyof EventMap<T>>(
  target: T,
  type: K,
  callback: (this: T, ev: EventMap<T>[K]) => any,
  options?: AddEventListenerOptions,
) {
  target.addEventListener(type, callback, options);
  return () => {
    target.removeEventListener(type, callback, options);
  };
}

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@kripod
Copy link
Author

kripod commented Aug 23, 2019

An alternative solution for the meantime for anyone interested:

export type EventMap<T> = T extends Window
  ? WindowEventMap
  : (T extends Document ? DocumentEventMap : { [key: string]: Event });

export function managedEventListener<
  T extends EventTarget,
  K extends keyof EventMap<T> & string
>(
  target: T,
  type: K,
  callback: (this: T, ev: EventMap<T>[K]) => any,
  options?: AddEventListenerOptions,
) {
  target.addEventListener(type, callback as EventListener, options);
  return () => {
    target.removeEventListener(type, callback as EventListener, options);
  };
}

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Aug 23, 2019
@calebdwilliams
Copy link

I've been looking for this feature as well, although I'd prefer if there were also a way to declare valid event types and names in a given interface (imagine creating interfaces for design systems that might have multiple implementations in web components, Angular, etc.). I'm not a TypeScript expert by any means, but something like the following

interface AccordionToggle extends CustomEvent {
    detail: Boolean;
}

interface Accordion {
  open: Boolean;
  toggle(): Boolean;

  [EventMap] 'accordion-toggle': AccordionToggle;
}

class XAccordion extends HTMLElement implements Accordion {
  open: Boolean;

  toggle() {
    this.open = !this.open;
    const toggleEvent: AccordionToggle = new CustomEvent('accordion-toggle', {
      composed: true,
      detail: this.open
    });
    this.dispatchEvent(toggleEvent);
    return this.open;
  }
}

Since TypeScript can now work with JSDocs, I'd hope this would be analogous to the @fires tag described there and would interop with that feature.

@jsejcksn
Copy link

jsejcksn commented Jul 6, 2020

Also, EventTarget should be able to accept a type argument which is an EventMap, so that classes can extend EventTargetand the corresponding listeners will recognize the appropriate types and events.

Here's an example:

type SpellCastEvent = CustomEvent<{
  id: number;
  effect: string;
  name: string;
}> & {
  type: 'spell_cast';
};

type SpellcasterEventMap = {
  spell_cast: SpellCastEvent;
};

class Spellcaster extends EventTarget<SpellcasterEventMap> {
  constructor() {
    super();
    // ...
  }
}

function spellEventHandler (event: SpellCastEvent) {
  console.log(`The ${event.detail.name} spell was cast.`);
}

const spellcaster = new Spellcaster();

spellcaster.addEventListener('spell_cast', spellEventHandler);

Right now, this causes an error when calling spellcaster.addEventListener(), and has to be addressed with a type assertion in the handler like so:

function spellEventHandler (event: Event) {
  const ev = (event as SpellCastEvent);
  console.log(`The ${ev.detail.name} spell was cast.`);
}

You can see the errors I'm describing in #39425.

@pushkine
Copy link
Contributor

pushkine commented Oct 6, 2020

The first solution coming to my mind is related to #26591

type EventTypes<El extends EventTarget> = Parameters<El["addEventListener"]>[0];

Yet there's also the fact that string | "custom_event_type" collapses to string
I can't really think of a good solution but in the mean time here's my usual goto

type EventMap<T extends EventTarget> = T extends MediaQueryList
	? MediaQueryListEventMap
	: T extends Document
	? DocumentEventMap
	: T extends Window
	? WindowEventMap
	: HTMLElementEventMap;
type EventTypes<T extends EventTarget> = keyof EventMap<T> & string;
type EventValue<T extends EventTarget, K extends EventTypes<T>> = Extract<EventMap<T>[K], Event>;

function listen<T extends EventTarget, K extends EventTypes<T>>(
	element: T,
	type: K,
	listener: (this: T, ev: EventValue<T, K>) => void,
	opts: AddEventListenerOptions
) {
	element.addEventListener(type, listener, opts);
	return element.removeEventListener.bind(element, type, listener, opts);
}

@mindplay-dk
Copy link

@pushkine works perfectly for me, thanks! why aren't these in the standard types already? 🙂

@zspitz
Copy link
Contributor

zspitz commented Jun 29, 2023

@pushkine I've tried your workaround unsuccessfully in TS Playground:

Playground

AFAICT, Extract<EventMap<T>[K], Event> is a narrower type than Event, thus trying to pass a parameter of type Event to listener might fail.

gingerchew added a commit to gingerchew/juhla that referenced this issue May 7, 2024
Prefix may be added back later on but for right now it is not a necessary

Other small changes include
- Changing j to J and let to const
- better typing of event listener options with AddEventListenerOptions
- Adds EventMap type from microsoft/TypeScript#33047 (comment)
- Updates JuhlaMethodsFunc & JuhlaEmitFunc with EventMap generic
- Removes JuhlaEventListenerOptions
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

7 participants