Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Web Development

Building a Lightweight and Type-Safe Event Bus in TypeScript

Create a type safe event bus typescript pub sub implementation mapped types system to guarantee compile-time safety for your application events.

Dian Rijal Asyrof/August 10, 2026/6 min read
Illustration for Building a Lightweight and Type-Safe Event Bus in TypeScript

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); // Clunky

We 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 CustomEvent object.
  • 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.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleTaming the Go Garbage Collector in Low-Latency Microservices
TypeScriptDesign PatternsEvent DrivenWeb Development
On this page↓
  1. The Problem with String-Based Emitters
  2. Step 1: Defining the Event Registry
  3. Step 2: Typing the Event Bus Interface
  4. Step 3: Handling Events with No Payload
  5. Step 4: Implementing the Class
  6. Step 5: Advanced Features - Wildcard Listeners
  7. Step 6: Integrating with React
  8. Step 7: Synchronous vs. Asynchronous Execution
  9. Why Not Use Native EventTarget?

On this page

  1. The Problem with String-Based Emitters
  2. Step 1: Defining the Event Registry
  3. Step 2: Typing the Event Bus Interface
  4. Step 3: Handling Events with No Payload
  5. Step 4: Implementing the Class
  6. Step 5: Advanced Features - Wildcard Listeners
  7. Step 6: Integrating with React
  8. Step 7: Synchronous vs. Asynchronous Execution
  9. Why Not Use Native EventTarget?

See also

Illustration for TypeScript Conditional and Mapped Types: A Practical Guide
Programming/Jul 3, 2026

TypeScript Conditional and Mapped Types: A Practical Guide

TypeScript conditional and mapped types sound intimidating at first. This guide shows you the patterns that actually show up in production codebases.

4 min read
TypeScriptType System
Illustration for Next.js: A Deep Dive into the React Framework for Production
Web Development/Jun 20, 2026

Next.js: A Deep Dive into the React Framework for Production

Explore the core features, architecture, and best practices for building high-performance, production-ready web applications with Next.js, the leading React framework.

6 min read
Next.jsReact
Illustration for Getting Started with Next.js: A Developer's Comprehensive Guide
Web Development/Jun 20, 2026

Getting Started with Next.js: A Developer's Comprehensive Guide

Learn how to use Next.js for building modern, full-stack React applications with server-side rendering, static site generation, and API routes.

6 min read
Next.jsReact