Typesafe CustomEvents on your Frontend

In a micro-services architecture such as the one at AutoScout24, it’s common to have different frontend components running within a given page, each with their own isolated internal state. When they need to communicate with each other, one proven mechanism is via DOM CustomEvent messages, using the Document root element as a message bus system.

This is straightforward to implement at the code level. However, there is no canonical way to know what events each micro-service is firing nor their associated payloads. To tackle this problem, we introduced a shared central repository for our entire frontend codebase to not only document each event, but also provide precise TypeScript type declarations that can be consumed by each codebase. We can now

avoid CustomEvent runtime bugs by having the compiler check them for us.

We also publish the declarations & names as a @autoscout24/custom-eventsnpm package so the event names and associated payloads can be integrated into the code easily. Take for example the AutoScout24 refined search page where the search filters produce an update every time they change. We can listen to them anywhere on the page from another component in a typesafe way

Typesafe event listener example

We can also produce events with the compiler checking that the event payload matches the correct event name. For instance, the React code that is in charge of updating the TotalCount button counter will generate an event with the totalCount field safely via

Typesafe event producer example

where strictCustomEvent is just a typesafe proxy for new CustomEvent(…) call (more details below).

Let’s take a look at some of the TypeScript features that make this all work.

TypeScript Global namespace augmentation

TypeScript provides the possibility of extending global interfaces such as the one provided by the standard DOM lib. We are interested in making the document.addEventListener method aware of our application-specific events. Its signature is

standard DOM `document.addEventListener` type declaration

The part we are interested on is the link between the event name and its payload. The name is represented by a generic K, which must be a key of the DocumentEventMap dictionary, and the payload ev is an index type of DocumentEventMap. This shows us that if we extend the DocumentEventMap interface, this method will be extended for free to include our custom events. Indeed, this is what we do:

Global augmentation example

Now the TypeScript compiler (and IDE’s) knows that the event variable in document.addEventListner(‘CL_FILTER_UPDATE’, event => {…}) has the type ClassifiedListFilterUpdate.

TypeScript Conditional Types

conditional types are powerful constructs that allow us to manipulate and transform the shape of types and establish relations between them.

In order to see the part they play in hardening our event emission, let’s take a look at how the CustomEvent interface is defined in the standard DOM lib:

Unfortunately, the non-generic typeArg: string signature prevents us from applying the same technique we used before to extend the document.addEventListener method. We are forced to add a proxy method strictCustomEvent that is the typesafe version of the plain new CustomEvent() constructor. This method is defined as

StrictCustomEvent proxy used to create specific CustomEvents safely

Let’s start with the helper types: the UnpackCustomEventPayload uses conditional types to extract the payload from a custom event when applied to such type, and the Omit method lets us exclude keys from a given type.

Turning our attention to the method signature, the T generic is constrained to be a key of the same`DocumentEventMap` we had previously extended and excludes the standard event names. That’s because by design we don’t want to allow calling strictCustomEvent on say a click event name. This generic is used to type the name argument and therefore represents the event name.

The second generic D is actually derived from the first, and indicates the event payload, as seen by the method’s return type. It’s also used to type the payload argument, which includes all the standard CustomEventInit properties plus the specific detail node.

At runtime, the TypeScript signatures are compiled away and the method becomes entirely equivalent to calling new CustomEvent(…).

Conclusion

Managing communication across multiple components living in separate codebases can be error-prone. Moving the event declarations to a common repository and packaging them into an npm package lets both producers and consumers eat their own dog food, preventing the typical problem of implementation and documentation going out of sync.

In addition, using the power of the TypeScript compiler we can have types for all of our event names & associated signatures, with proper compile-time checks. This leads to increased developer speed and reduced runtime bugs, which is a win for everyone.