Type-Safe Event listeners in TypeScript

June 13, 2021

Hey, everyone!

A few weeks ago a stumbled upon a tweet from Alex Reardon, where he was asking to help with TS typing of his package called bind-event-listener.

I’ve dealt with this issue in one of my projects, so I decided to open a PR. However, it wasn’t as easy as I thought it would be (like almost everything in life).

So I decided to also write an article and share what I’ve learned from this, so you don’t have to go through the same process as me.

Initial idea

As I already told you, I had dealt with this issue in my project. And the solution was simple - overloads.

I had a function called on that was typed like that:

type Listener<Ev extends Event> =
  | { handleEvent(ev: Ev): void }
  // using bivarianceHack here so that user
  // could narrow the type of `ev` by hand
  | { bivarianceHack(ev: Ev): void }['bivarianceHack'];

type Off = () => void;

function on<Target extends HTMLElement, Type extends keyof HTMLElementEventMap>(
  target: Target,
  type: Type,
  listener: Listener<HTMLElementEventMap[Type]>
): Off;
function on<Target extends SVGElement, Type extends keyof SVGElementEventMap>(
  target: Target,
  type: Type,
  listener: Listener<SVGElementEventMap[Type]>
): Off;
// ...
function on(target: EventTarget, type: string, listener: Listener<Event>): Off;
function on(target: EventTarget, type: string, listener: Listener<Event>) {
  // ...
}

Note that this is an example from my project, bind-event-listener has a little different API.

Also note that I leverage bivariance hack to allow type narrowing by hand. More on it here.

TypeScript inside it’s lib.dom.d.ts has EventMaps that are used to correctly type addEventListener on the each event target.

So I thought that all I would need to do is just add more overloads to handle all of the event targets.

But to my surprise, there were a lot more of them than I initially thought. Which, by the way, totally makes sense.

Almost everything in JS has events that you could subscribe to. So I quickly understood that the problem is a little bit more difficult.

Brainstorming

Since I’ve already pulled repository, created branch, etc. I decided to finish the work that I’ve started.

After the quick brainstorm session I came up with 4 ideas:

  • Build a union of possible type/event combinations from the target’s on* handlers
  • Using passed type to check to extract event type from on${type} handler
  • Make a huge mapping of *EventMap’s to targets, and get the event map through conditional type
  • Create overloads with all cases through code generation

So let’s go through all of them, and see what I did.

Build a union of possible type/event combinations from the target’s on* handlers

In case you don’t know, instead of using addEventListener, you directly set an on* function to the target, and it’ll be called when the event is fired:

const el = document.querySelector('.item');

el.onclick = e => console.log(e.target.innerHTML);

Therefore, TS has a correct typing for such handlers in each event target.

And since it has been few months since TypeScript 4.1 came out, my first idea was to leverage its main feature - literal types to generate a union of possible event types.

The idea was to generate something like this:

| {
    type: 'abort';
    listener: Listener<UIEvent>;
    options?: boolean | AddEventListenerOptions;
  }
| ...
| {
  type: 'mousemove';
  listener: Listener<MouseEvent>;
  options?: boolean | AddEventListenerOptions;
}

I quickly wrote a type that would build this unions:

type AnyFunction = (...any: any[]) => any;

interface Binding<Ev extends Event, Type extends string> {
  type: Type;
  listener: Listener<Ev>;
  options?: boolean | AddEventListenerOptions;
}

type GetAllPairs<Target extends EventTarget> = Exclude<
  {
    [K in keyof Target]: K extends `on${infer Type}`
      ? GetPair<Target[K], Type>
      : never;
  }[keyof Target],
  undefined
>;

type GetPair<T, K extends string> = T extends AnyFunction
  ? Binding<GetEventTypeFromListener<T>, K>
  : never;

type GetEventTypeFromListener<T extends AnyFunction> = T extends (
  this: any,
  event: infer U
) => any
  ? U extends Event
    ? U
    : Event
  : Event;

It worked great.

However, there was one problem. It wasn’t possible to attach an event that doesn’t have a handler on the target (e.g. DOMContentLoaded), since a union only has those types.

The obvious solution would be to just add a fallback item to the union like that:

type GetAllPairs<Target extends EventTarget> =
  | Exclude<
      {
        [K in keyof Target]: K extends `on${infer Type}`
          ? GetPair<Target[K], Type>
          : never;
      }[keyof Target],
      undefined
    >
  | Binding<Event, string>;

But, if you try to test this type you will quickly realize that it’s not that simple:

type UnbindFn = () => void;

function bind<Target extends EventTarget>(
  target: Target,
  { type, listener, options }: Binding<Target>
): UnbindFn {
  // ...
}

bind(window, {
  type: 'abort',
  listener(e) {
    //    ^^^
    // Parameter 'e' implicitly has an 'any' type.ts(7006)
    /* ... */
  },
});

The TypeScript will give you an error saying that type of e is any.

This happens because type matches 2 union elements - fallback and abort items.

So the listener will be of type Listener<UIEvent> | Listener<Event>, and when we have union type for function in TS, the argument will be inferred to any because the compiler itself couldn’t determine which of the union items is used.

And at this point, I decided to try something else.

Using passed type to check to extract event type from on${type} handler

So after the first attempt I came up with an idea to have a generic for type and use it to extract event type from handler. In this case we could fallback to Event since we would use conditional types:

type GetEventType<
  Target extends EventTarget,
  Type extends string
> = Target extends unknown
  ? `on${Type}` extends keyof Target
    ? GetEventTypeFromListener<
        // remove types that aren't assignable to `AnyFunction` (e.g. null | undefined)
        // so that we don't end up with union like `MouseEvent | Event`
        Extract<Target[`on${Type}`], AnyFunction>
      >
    : Event
  : never;

type GetEventTypeFromListener<T extends AnyFunction> = T extends (
  this: any,
  event: infer U
) => any
  ? U extends Event
    ? U
    : Event
  : Event;

type Binding<
  Target extends EventTarget = EventTarget,
  Type extends string = string
> = {
  type: Type;
  listener: Listener<GetEventType<Target, Type>>;
  options?: boolean | AddEventListenerOptions;
};

export function bind<Target extends EventTarget, Type extends string>(
  target: Target,
  { type, listener, options }: Binding<Target, Type>
): UnbindFn;
export function bind(
  target: EventTarget,
  { type, listener, options }: Binding
) {
  target.addEventListener(type, listener, options);

  return function unbind() {
    target.removeEventListener(type, listener, options);
  };
}

Note that I have Target extends unknown condition in GetEventType. We need to have it to make sure that Target type is correctly distributed. For more info check out this article.

In the code, we are still extracting event type from the target. However, since we added a generic Type, we can check if on${Type} key exists on the target.

And if not or if it’s not a function, we fall back to Event.

Although this solution worked correctly, I still wasn’t satisfied and tried to test other ideas.

Make a huge mapping of targets to their *EventMap’s and get the event map through conditional type

At this point, I thought why not just spent some time extracting all the EventMaps from lib.dom.d.ts.

It’s something that you have to do once, at the end of the day.

However, I didn’t do it by hand.

I quickly set up a script that was parsing the type definitions file and building the map for me:

const fs = require('fs');
const path = require('path');

function generateEventMapEntries() {
  const eventMapsMap = Object.create(null);

  const libDOM = fs.readFileSync(
    path.resolve(__dirname, '../node_modules/typescript/lib/lib.dom.d.ts'),
    'utf-8'
  );

  libDOM.split(' ').forEach(token => {
    if (token.endsWith('EventMap')) {
      const targetType = token.substr(0, token.length - 8);
      eventMapsMap[targetType] = token;
    }
  });
  // sort the types by length, so that more specific types
  // get matched first (e.g. HTMLElement and HTMLFrameSetElement)
  const targetAndMapEntries = Object.entries(eventMapsMap).sort(
    (a, b) => b[0].length - a[0].length
  );

  return targetAndMapEntries;
}

As a result, I ended up with a huge generated mapping of targets to their event maps like this:

type DOMEventMapDefinitions = [
  [DocumentAndElementEventHandlers, DocumentAndElementEventHandlersEventMap],
  [ServiceWorkerRegistration, ServiceWorkerRegistrationEventMap],
  [XMLHttpRequestEventTarget, XMLHttpRequestEventTargetEventMap]
  // ... 63 more items
];

The DOMEventMapDefinitions type will allow us to create a conditional type that will iterate through items in the map and find the event map for a passed target.

So, this is the type that I’ve built:

type GetEventType<
  Target extends EventTarget,
  Type extends string
> = CastToEvent<GetEventType_<Target, Type>>;

type GetEventType_<Target extends EventTarget, Type extends string> = {
  [K in keyof DOMEventMapDefinitions]: DOMEventMapDefinitions[K] extends [
    infer TargetType,
    infer EventMap
  ]
    ? Target extends TargetType
      ? Type extends keyof EventMap
        ? EventMap[Type]
        : never
      : never
    : never;
}[keyof DOMEventMapDefinitions];

// if `Target` doesn't match any item form the
// `DOMEventMapDefinitions`, we fallback to Event
type CastToEvent<T> = IsNever<T> extends true ? Event : T;

type IsNever<T> = [T] extends [never] ? true : false;

export function bind<Target extends EventTarget, Type extends string>(
  target: Target,
  { type, listener, options }: Binding<Type, GetEventType<Target, Type>>
): UnbindFn;
export function bind(
  target: EventTarget,
  { type, listener, options }: Binding
): UnbindFn {
  target.addEventListener(type, listener, options);

  return function unbind() {
    target.removeEventListener(type, listener, options);
  };
}

In the code above, note that I use never for the fallback cases. I do this due to the reason that never is an empty union.

Therefore, if one of the items matches the target, all these never’s will not be in a resulted type.

But if we don’t match anything, we have a CastToEvent type to fall back to Event if the result is never.

As you can see, it’s a workable solution, however, it’s a bit verbose, and relies on lib.dom.d.ts types. And if something changes there, we’d have to update our DOMEventMapDefinition.

However, since I’ve already used a bit of code generation here, I thought why not go all in and generate overloads themselves.

Create overloads with all cases through code generation

We already have been generating a mapping of the targets to events maps in the previous example.

Right now we have to do leverage this mapping to generate an actual overloads:

const fs = require('fs');
const path = require('path');

try {
  // remove generated file if exists, before anything
  fs.unlinkSync(path.resolve(__dirname, './bind-generated.ts'));
} catch (e) {
  console.error(e);
}

function generateEventMapEntries() {
  const eventMapsMap = Object.create(null);

  const libDOM = fs.readFileSync(
    path.resolve(__dirname, '../node_modules/typescript/lib/lib.dom.d.ts'),
    'utf-8'
  );

  libDOM.split(' ').forEach(token => {
    if (token.endsWith('EventMap')) {
      const targetType = token.substr(0, token.length - 8);
      eventMapsMap[targetType] = token;
    }
  });
  // sort the types by length, so that more specific types
  // get matched first (e.g. HTMLElement and HTMLFrameSetElement)
  const targetAndMapEntries = Object.entries(eventMapsMap).sort(
    (a, b) => b[0].length - a[0].length
  );

  return targetAndMapEntries;
}

// import the typings
const importStatements = `import { UnbindFn, Binding } from './types'`;

const bindBody = `{
  target.addEventListener(type, listener, options);
  return function unbind() {
    target.removeEventListener(type, listener, options);
  };
}`;

const generateOverload = (target, eventMap, hasBody) => {
  const [typeConstraint, event] =
    typeof eventMap === 'string'
      ? [`keyof ${eventMap}`, `${eventMap}[Type]`]
      : ['string', 'Event'];

  const end = hasBody ? `UnbindFn ${bindBody}` : `UnbindFn;`;

  return `export function bind<
    Target extends ${target}, 
    Type extends ${typeConstraint}
  >(
    target: Target, 
    { type, listener, options }: Binding<Type, ${event}>
  ): ${end}`;
};

// implementation and fallback are always the same
const implementationAndFallback = [
  generateOverload('EventTarget', null, false),
  generateOverload('EventTarget', null, true),
];

const overloads = generateEventMapEntries().map(entry =>
  generateOverload(...entry, false)
);

const result = [
  importStatements,
  '',
  ...overloads,
  ...implementationAndFallback,
].join('\n');

fs.writeFileSync(path.resolve(__dirname, './bind-generated.ts'), result);

In the code above we use type parsed type information from dom declaration files to generate an actual TS code.

To make the generated code work correctly we must also have a types.ts file with the following types:

export type UnbindFn = () => void;

export type Binding<Type extends string = string, Ev extends Event = Event> = {
  type: Type;
  listener: Listener<Ev>;
  options?: boolean | AddEventListenerOptions;
};

type Listener<Ev extends Event> =
  | { handleEvent(e: Ev): void }
  | { bivarianceHack(e: Ev): void }['bivarianceHack'];

The generated bind will look like this:

export function bind<
  Target extends DocumentAndElementEventHandlers,
  Type extends keyof DocumentAndElementEventHandlersEventMap
>(
  target: Target,
  {
    type,
    listener,
    options,
  }: Binding<Type, DocumentAndElementEventHandlersEventMap[Type]>
): UnbindFn;
export function bind<
  Target extends ServiceWorkerRegistration,
  Type extends keyof ServiceWorkerRegistrationEventMap
>(
  target: Target,
  {
    type,
    listener,
    options,
  }: Binding<Type, ServiceWorkerRegistrationEventMap[Type]>
): UnbindFn;
// ...

Although this solution has a lot of code, it’d be simpler for a user to understand an error, since when you click on the function, it takes you exactly to the matched overload.

And this is a big advantage of this solution.

However, it also relies on the definition files, so it may require additional updates of the package.

The result

I guess if you’re still reading this article, you wonder which of these 4 solutions has been chosen for the bind-event-lister package.

Under the initial PR, I proposed to use the first solution (the one that extracts event from target handler).

The author (Alex) was happy to go with it, and I’ve opened another PR where I improved the solution a bit and added type tests.

Conclusion

As you can see with this example, there is always more than one solution to a problem.

Next time you face some issue, whether it TypeScript or not, try to start from scratch and you’ll most likely come up with another solution, which, potentially, could be better.

That’s it for today, if you liked this article, make sure to share it with your friends.

Take care!


© 2023 Ayub Begimkulov All Rights Reserved