We have all seen the classic publish-subscribe pattern. In JavaScript, it is everywhere. Node's EventEmitter is the backbone of half the ecosystem. But when we move to TypeScript, this pattern usually loses its teeth. We often end up with code like emitter.on('user-logged-in', (data: any) => ...) where any acts as a silent agreement to let bugs slip into production.
If you change a payload shape in one file, the compiler won't warn you that a listener three folders away is now broken. That is a bad developer experience.
We can do better. By using TypeScript conditional and mapped types, generic constraints, and indexed access types, we can build an event bus that is completely type-safe, lightweight, and has zero runtime dependencies, which is a great practice for frontend bundle optimization.
The Problem with String-Based Emitters
Let's look at why standard event emitters fail us. A typical untyped event bus looks something like this:
class UntypedEventBus {
private listeners: Record<string, Function[]> = {};
on(event: string, callback: Function) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
emit(event: string, data: any) {
const eventListeners = this.listeners[event] || [];
eventListeners.forEach((cb) => cb(data));
}
}This code works fine at runtime. But it has major flaws during development:
- You can publish any event name, even if it is misspelled (e.g.,
emit('user-loged-in')). - The payload type is
any, meaning you get no autocomplete when writing consumer code. - If the publisher changes the payload structure, consumers will fail silently at runtime.
We need a way to bind specific event names to specific payload shapes.
Step 1: Defining the Event Registry
Instead of allowing arbitrary strings, we need a single source of truth for our events. We can define this as a simple interface where the keys represent event names and the values represent the payload shapes.
interface AppEventRegistry {
'user:logged-in': { userId: string; timestamp: number };
'cart:item-added': { itemId: string; price: number; quantity: number };
'ui:theme-changed': { theme: 'light' | 'dark' };
'system:error': Error;
}This interface acts as our contract. If an event has no payload, we can use void or undefined.
interface AppEventRegistry {
// ... other events
'auth:logged-out': void;
}Step 2: Typing the Event Bus Interface
Now we need to design a class that accepts this registry as a generic parameter. We will call this parameter Events and constrain it to extend Record<string, any>.
class EventBus<Events extends Record<string, any>> {
// Implementation details will go here
}Let's think about the signatures for the on and emit methods.
We want the first argument of on to be a valid key of our registry. TypeScript gives us the keyof operator for this. The second argument should be a callback function. The argument of this callback function must match the type of the payload associated with that specific key.
We can express this using an indexed access type: Events[Key].
type Listener<T> = (payload: T) => void;
class EventBus<Events extends Record<string, any>> {
on<Key extends keyof Events>(event: Key, callback: Listener<Events[Key]>): void {
// ...
}
emit<Key extends keyof Events>(event: Key, payload: Events[Key]): void {
// ...
}
}By using the generic parameter Key on the method level, TypeScript infers the exact event we are working with. If you pass 'user:logged-in' as the event, the compiler knows that payload in the callback must be { userId: string; timestamp: number }.
Step 3: Handling Events with No Payload
What happens when an event has a void or undefined payload? With our current signature, you would still have to pass undefined as the second argument to emit:
bus.emit('auth:logged-out', undefined); // ClunkyWe can make the payload argument optional if the event's payload type allows for it. We can use a conditional type or function overloads to handle this. Let's use parameters with tuple types to make this clean.
type PayloadArg<T> = T extends void | undefined ? [] : [payload: T];
class EventBus<Events extends Record<string, any>> {
// ...
emit<Key extends keyof Events>(event: Key, ...args: PayloadArg<Events[Key]>): void {
const payload = args[0];
// ...
}
}Now, if the payload is void, the compiler expects zero additional arguments. If it has a type, it expects exactly one.
bus.emit('auth:logged-out'); // Valid!
bus.emit('user:logged-in', { userId: '123', timestamp: Date.now() }); // Valid!Step 4: Implementing the Class
The internal implementation of the event bus needs to store these listeners. Inside the class, we lose some type safety because we are grouping different listener types into a single collection. We can use a Map to store the lists of callbacks.
export class EventBus<Events extends Record<string, any>> {
private listeners = new Map<keyof Events, Set<Function>>();
on<Key extends keyof Events>(event: Key, callback: Listener<Events[Key]>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const eventListeners = this.listeners.get(event)!;
eventListeners.add(callback);
// Return an unsubscribe function directly for clean resource management
return () => {
eventListeners.delete(callback);
if (eventListeners.size === 0) {
this.listeners.delete(event);
}
};
}
emit<Key extends keyof Events>(event: Key, ...args: PayloadArg<Events[Key]>): void {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
const payload = args[0];
// We clone the listeners set to prevent bugs if a listener unsubscribes during emission
const listenersToExecute = Array.from(eventListeners);
listenersToExecute.forEach((callback) => {
try {
callback(payload);
} catch (error) {
console.error(`Error in event listener for "${String(event)}":`, error);
}
});
}
// Clear all listeners for a specific event, or all events
offAll<Key extends keyof Events>(event?: Key): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}Notice that the on method returns an unsubscribe function. This is a common pattern that avoids having to keep references to callback functions just to remove them later.
Step 5: Advanced Features - Wildcard Listeners
Sometimes you need to listen to every event that passes through the bus. This is useful for logging, analytics, or debugging.
To type a wildcard listener, we need to receive the event name and the payload together. But we also want to preserve the relationship between them. If the event is 'system:error', the payload must be an Error.
We can represent this relationship using a mapped union type.
type EventEntry<Events, Key extends keyof Events> = {
type: Key;
payload: Events[Key];
};
// This maps over all keys in Events and creates a union of all possible event objects
type AllEventsUnion<Events> = {
[K in keyof Events]: EventEntry<Events, K>;
}[keyof Events];
type WildcardListener<Events> = (event: AllEventsUnion<Events>) => void;Let's add wildcard support to our class.
export class EventBus<Events extends Record<string, any>> {
private listeners = new Map<keyof Events, Set<Function>>();
private wildcardListeners = new Set<WildcardListener<Events>>();
onAny(callback: WildcardListener<Events>): () => void {
this.wildcardListeners.add(callback);
return () => {
this.wildcardListeners.delete(callback);
};
}
emit<Key extends keyof Events>(event: Key, ...args: PayloadArg<Events[Key]>): void {
const payload = args[0];
// Trigger specific listeners
const eventListeners = this.listeners.get(event);
if (eventListeners) {
Array.from(eventListeners).forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error(err);
}
});
}
// Trigger wildcard listeners
if (this.wildcardListeners.size > 0) {
const eventData = { type: event, payload } as AllEventsUnion<Events>;
Array.from(this.wildcardListeners).forEach((cb) => {
try {
cb(eventData);
} catch (err) {
console.error(err);
}
});
}
}
}Now, if you subscribe via onAny, you get a typed object where you can narrow down the payload using a switch statement or an if block.
const bus = new EventBus<AppEventRegistry>();
bus.onAny((event) => {
if (event.type === 'cart:item-added') {
// TypeScript knows event.payload is { itemId: string; price: number; quantity: number }
console.log(`Added ${event.payload.itemId} to cart`);
}
});Step 6: Integrating with React
If you are using this in a frontend application, such as a React app built with a framework like Next.js for production, you will want a clean way to hook into these events inside your components. We can create a React hook that handles subscribing on mount and unsubscribing on unmount automatically.
import { useEffect, useRef } from 'react';
export function useEvent<Events extends Record<string, any>, Key extends keyof Events>(
bus: EventBus<Events>,
event: Key,
callback: Listener<Events[Key]>
) {
// Use a ref to avoid re-subscribing if the callback changes on every render
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
const listener = (payload: Events[Key]) => savedCallback.current(payload);
const unsubscribe = bus.on(event, listener);
return unsubscribe;
}, [bus, event]);
}This prevents memory leaks by cleaning up the subscription when the component unmounts.
// Component usage
export const CartNotification = ({ bus }: { bus: EventBus<AppEventRegistry> }) => {
useEvent(bus, 'cart:item-added', (item) => {
alert(`Added ${item.itemId} to your cart!`);
});
return null;
};Step 7: Synchronous vs. Asynchronous Execution
Our current implementation is synchronous. When you call emit, it loops through the listeners and executes them immediately on the same tick of the event loop.
If one listener performs a heavy computation, it blocks the rest.
If you want an asynchronous event bus, you can schedule the execution of listeners using queueMicrotask or setTimeout.
emit<Key extends keyof Events>(event: Key, ...args: PayloadArg<Events[Key]>): void {
const payload = args[0];
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.forEach((callback) => {
// Defer execution to the next microtask
queueMicrotask(() => {
try {
callback(payload);
} catch (error) {
console.error(error);
}
});
});
}
}Using queueMicrotask keeps the execution order predictable while ensuring that the caller of emit does not get blocked by slow listener logic.
Why Not Use Native EventTarget?
Modern browsers and Node.js now support the EventTarget class, which you can extend. It is built-in, but it has some downsides when it comes to type safety:
- It requires wrapping payloads inside a
CustomEventobject. - The API is verbose (
dispatchEvent(new CustomEvent('name', { detail: payload }))). - Getting full type safety across different event names requires writing complex wrappers around the native methods anyway.
A custom class gives us complete control over the API signature, allowing for the clean bus.emit('event', payload) syntax.
Using mapped types and generics to build infrastructure like this keeps your codebase clean. The compiler does the heavy lifting of verifying that your events and payloads match up across your application boundaries.



