DEV Community

Cover image for Type-Safe CustomEvents: Better Messaging with Native APIs
Andrew Bone
Andrew Bone

Posted on

Type-Safe CustomEvents: Better Messaging with Native APIs

The native EventTarget is a hidden gem for internal messaging. It is built-in, fast and stays out of your way. However, the standard CustomEvent interface is a bit of a "black box" for TypeScript. Usually, you end up casting e as CustomEvent<MyData> every time you want to access your data.

We can do better. By wrapping the target and the event creation, we can have a completely type-safe event bus with zero runtime overhead.

The Problem

When you dispatch a CustomEvent, the data is tucked away in the detail property. Out of the box, addEventListener has no idea what that detail contains, forcing you to manually type your listeners.

The Solution: TypedEventTarget

We can use a generic map to link event names to their specific CustomEvent payloads.

type EventListener<E extends Event> = (evt: E) => void;

interface EventListenerObject<E extends Event> {
  handleEvent(evt: CustomEvent<E>): void;
}

// The type of our listener receives the CustomEvent with our specific data
type TEL<E> = EventListener<CustomEvent<E>> | EventListenerObject<CustomEvent<E>>;

export class TypedEventTarget<M extends Record<string, unknown>> {
  private readonly target = new EventTarget();

  addEventListener<K extends keyof M>(
    type: K & string,
    listener: TEL<M[K]>,
    options?: boolean | AddEventListenerOptions,
  ) {
    // We cast to EventListenerOrEventListenerObject because the browser expects the base Event type
    this.target.addEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  removeEventListener<K extends keyof M>(
    type: K & string,
    listener: TEL<M[K]>,
    options?: boolean | EventListenerOptions,
  ) {
    this.target.removeEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  // A helper to ensure we always dispatch a properly formatted CustomEvent
  dispatchEvent<K extends keyof M>(type: K, ...args: M[K] extends void ? [detail?: undefined] : [detail: M[K]]) {
    const [detail] = args;

    return this.target.dispatchEvent(new CustomEvent(String(type), { detail }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Of course this is only a base class, we'll need to extend it in order to use it with our own data.

Real-world usage

Let's imagine a situation where we want to track an online shopping cart. We want to be able to make changes to the cart from anywhere in the site, but we don't want the UI or the total price to go out of sync.

import { TypedEventTarget } from './TypedEventTarget';

export interface CartItem {
  id: string;
  name: string;
  price: number;
}

// This map defines exactly what data each event emits
type ShoppingCartEvents = {
  'item-added': CartItem;
  'item-removed': { id: string };
  'cart-cleared': void;
};

export default class ShoppingCart extends TypedEventTarget<ShoppingCartEvents> {
  private _items: CartItem[] = [];

  addItem(item: CartItem) {
    this.items.push(item);
    this.dispatchEvent('item-added', item);
  }

  removeItem(id: CartItem['id']) {
    this._items = this._items.filter((item) => item.id !== id);
    this.dispatchEvent('item-removed', { id });
  }

  clear() {
    this._items = [];
    this.dispatchEvent('cart-cleared');
  }

  get items() {
    return this._items;
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a class to store our data, you can see we've added methods that dispatch events automatically. Because these events are strictly typed, we get full autocomplete and safety when listening to them:

shoppingCartSingleton.addEventListener('item-added', (event)=> {
  const item = event.detail; // types know we've got CartItem;

  console.log(item)
});
Enter fullscreen mode Exit fullscreen mode

Demo

Here is a simple demo using the above class.

Signing-off

I've only really touched the surface of what's possible with this technique. We live in a world where there are many mainstream frameworks and each has a different way of doing things. I think we're on the cusp of seeing the return of the generalised singleton: one native JS/TS class that can be connected to the framework of your choice. But that's an opinion for another day and perhaps another post.

Thanks for reading! If you'd like to connect, here are my BlueSky and LinkedIn profiles. Come say hi 😊

Top comments (7)

Collapse
 
theminimalcreator profile image
Guilherme Zaia

Solid abstraction, but here's the catch: You've traded runtime safety for compile-time complexity.

That TEL<M[K]> casting is exactly what TS generics do best—until you need dynamic event names or plugin systems. Then you're back to as unknown as.

Real question: How does this scale when you have 50+ event types? Does the type map become unmaintainable, or do you split it into modules?

(Also: EventTarget is underrated for decoupling. More devs should use it instead of reinventing PubSub.)

Collapse
 
link2twenty profile image
Andrew Bone

You certainly are sacrificing flexibility for type safety. I will tend to use several of these classes, split by purpose, rather than having a single large class and I don't ever extend those classes. I normally end up with 10 or so events per class though I can imagine a really large codebase getting crazy complex.

That being said the TS rewrite in Go will certainly help with compile times and cope with a lot of event types.

Collapse
 
trinhcuong-ast profile image
Kai Alder

This is really neat. I've been doing something similar but way less elegant — basically just a plain object with event name keys mapping to callback arrays, which obviously gives you zero type safety.

The dispatchEvent overload with the conditional tuple type for void events is a nice touch. I always end up with awkward undefined params when an event doesn't carry data.

One thing I'm wondering — have you run into issues with memory leaks when listeners aren't properly cleaned up? With the native EventTarget you don't get a once equivalent on removeEventListener, so I've had to be pretty disciplined about cleanup in SPAs. Do you add any kind of dispose pattern on top of this?

Collapse
 
link2twenty profile image
Andrew Bone • Edited

For removing event listeners you can either use the native once, this will disconnect the event listener once it's been caught once.

// set the event listener to once
shoppingCartSingleton.addEventListener('item-added', (event) => { ... }, {
  once: true
});
Enter fullscreen mode Exit fullscreen mode

Or, my preferred method, you can send an abort signal to remove as many event listeners as you like.

// declare an abort controller
const ac = new AbortController();

// pass the signal through to the event listener
shoppingCartSingleton.addEventListener('item-added', (event) => { ... }, {
  signal: ac.signal
});

// abort the AbortController to remove the event listener
ac.abort();
Enter fullscreen mode Exit fullscreen mode

For example in react you would do something like this

useEffect(() => {
  const ac = new AbortController();

  // attach 3 listeners all with the same signal
  shoppingCartSingleton.addEventListener('item-added', (event) => { ... }, { signal: ac.signal });
  shoppingCartSingleton.addEventListener('item-removed', (event) => { ... }, { signal: ac.signal });
  shoppingCartSingleton.addEventListener('cart-cleared', (event) => { ... }, { signal: ac.signal });

  // on unmount, all 3 listeners are removed.
  return () => ac.abort();
}, [])
Enter fullscreen mode Exit fullscreen mode
Collapse
 
moopet profile image
Ben Sinclair

This is really good but it also reminds me how difficult typescript gets to follow once you start using it in earnest. As some point it gets worse than 4 levels of nested ifs.

Collapse
 
link2twenty profile image
Andrew Bone

TS definitions can get a little complicated but if you set up the complex stuff once (like this) it makes the codebase so much easier to use for whoever will be working on it.

I do think unless you're working in a shared codebase or are planning on publishing your code to NPM these tricks aren't all that necessary.

Collapse
 
link2twenty profile image
Andrew Bone

I’ve been leaning much more heavily into native APIs lately to keep my logic decoupled from whatever framework I'm using at the time. I'd love to hear if anyone else is doing anything similar.