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 (1)

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.