June 14, 2021
About 4 minutes
TypeScript Type theory Design

Events and listeners in TypeScript

Clever metatypes offer an extensible approach to type safety

Contents

Recently I had to write TypeScript type declarations (a .d.ts file) for an API with DOM-style event handling, as in addEventListener("event", eventListener). If you have used DOM events in TypeScript you’ve probably noticed that the standard DOM library can perform some impressive type inferences. Studying their technique is a good place to start if you are ready for a deeper understanding of the language’s type system.

What it looks like

Open an IDE with code completion and start typing something like document.body.addEventListener("moused. You’ll see that it offers mousedown as a completion. Accept that completion, move to the second argument, the IDE knows that your listener function will receive a MouseEvent. Whereas if you had instead written "keydown", that listener argument would instead receive a KeyboardEvent.

You could accomplish this with method overloads. Something like this:

interface EventEmitter {
addEventListener(type: "type1", listener: (ev: 1) => any): void;
addEventListener(type: "type2", listener: (ev: 2) => any): void;
addEventListener(type: "type3", listener: (ev: 3) => any): void;
}

// code completion will offer listener: (ev: 2) => any
(e as EventEmitter).addEventListener("type2",

That would work, but it’s verbose and hard to maintain. So what did the DOM library authors do instead? If you search around the source code, you’ll eventually find examples like this:

interface Document extends Node, DocumentAndElementEventHandlers, DocumentOrShadowRoot, GlobalEventHandlers, NonElementParentNode, ParentNode, XPathEvaluatorBase {
// ...
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

Just two overloads for addEventListener. Hmmm. So what is this DocumentEventMap?

interface DocumentEventMap extends GlobalEventHandlersEventMap, DocumentAndElementEventHandlersEventMap {
"fullscreenchange": Event;
"fullscreenerror": Event;
"pointerlockchange": Event;
"pointerlockerror": Event;
"readystatechange": Event;
"visibilitychange": Event;
}

OK, now this looks interesting.

How it works

Let’s simplify: First, get rid of the options argument—it isn’t relevant here. Neither is the second overload for each of addEventListener and removeEventListener: these handle cases where the event type can’t be proven to be one of the known types. (For example, if you specify the type with a string variable.) And since removeEventListener has the same signatures as addEventListener, we can ignore it, too. Finally, abstract away that these are Document events. That leaves something like this:

interface EventMap {
"mousedown": MouseEvent;
"another": AnotherEventType;
"yetanother": YetAnotherEventType;
// ...
}

interface EventEmitter {
addEventListener<E extends keyof EventMap>(type: E, listener: (ev: EventMap[E]) => any): void;
}

The EventMap interface is interesting: it isn’t meant to be implemented! Rather, it describes the relationship between kinds of events ("mousedown") and the types their listeners receive (MouseEvent). The purpose of EventMap is to give this mapping a type name.

The other secret ingredient is the type variable E in addEventListener. You may recall that type variables describe a relationship between two or more types. For example, this type describes a function which takes two arguments and returns one, all of which have the same type:

type Reducer =  <T>(a: T, b: T) => T;

Keep in mind that T is not a type. It stands in for a type in the way x stands for an unknown number in an equation. You can pass a value of any type as the first argument to a Reducer, but by doing so you give T that type, and so you must substitute the same type everywhere that T appears in the signature.

The relationship described by E is more complicated than that for T, but not by much. While T could be any type we like, the declaration of E uses extends to constrain what an E can be. Namely, an E must be a keyof EventMap. The keyof means, essentially, “one of the property names in type EventMap.” So, E can be one of "mousedown", "another", etc. At this point, the type argument to addEventListener makes sense: it is of type E, so, one of the known event type strings.

The type of the listener argument is (ev: EventMap[E]) => any. In other words, a function that takes a single argument of type EventMap[E]. (You might expect the return type => void, but the => any is more flexible. It says that the caller doesn’t care if the function returns a value because it will be ignored.) The purpose of EventMap[E] is easier to work out than it is to describe: the type of the argument is the same type as that declared for key E in EventMap.

Confused? Although this code mimics JavaScript property lookup syntax (object["key"]), none of these elements are objects. They are types. These are not instructions for the program to carry out at run time, they are instructions for TypeScript to follow at compile time in order to determine what types the arguments should accept.

This is a clever design. It is cleaner and easier to read than using overloads. It is also easier to maintain. When a new kind of event is added, the only change needed is adding an entry to EventMap. And if the signature of addEventListener ever changes, there are only two overloads to update.

I do wish that there was a standard way to indicate that EventMap is a helper type and not meant to be used directly. The TypeScript libraries use a lot of these types, and each can add to cognitive load. For example, if I type let e: E the IDE will offer EventMap as a completion even though e would never have that type in practice. This could be avoided if the compiler and other tools understood EventMap’s true nature.

Have a comment or correction? Let me know! I don’t use an automated comment system on this site, but I do appreciate feedback and I update pages accordingly.