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 (7)
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 toas 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:
EventTargetis underrated for decoupling. More devs should use it instead of reinventing PubSub.)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.
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
dispatchEventoverload with the conditional tuple type for void events is a nice touch. I always end up with awkwardundefinedparams 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
onceequivalent on removeEventListener, so I've had to be pretty disciplined about cleanup in SPAs. Do you add any kind ofdisposepattern on top of this?For removing event listeners you can either use the native
once, this will disconnect the event listener once it's been caught once.Or, my preferred method, you can send an abort signal to remove as many event listeners as you like.
For example in react you would do something like this
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.
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.
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.