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 }));
}
}
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);
}
}
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)
});
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)
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.