Why a Typed Event Bus Matters

In a medium‑size frontend codebase I work on, several feature modules need to talk to each other without creating a tangled web of direct imports. A lightweight event bus solves the coupling problem, but a plain EventEmitter quickly becomes a source of runtime bugs — listeners expect a payload shape that the emitter never guarantees. TypeScript can enforce that contract at compile time if we model events as a discriminated union and make the bus generic over that union.

The Core Types

Start by defining every event your app can emit. Each variant carries a type literal and a payload that matches that type.

type AppEvent =
  | { type: 'user:login'; payload: { userId: string; roles: string[] } }
  | { type: 'user:logout'; payload: { userId: string } }
  | { type: 'notification:show'; payload: { message: string; level: 'info' | 'warn' | 'error' } }
  | { type: 'data:refresh'; payload: { resource: string; params?: Record } };

The type field is the discriminant. TypeScript narrows the payload automatically inside a switch or if block, giving you full autocomplete and exhaustiveness checking.

A Minimal, Fully‑Typed Bus

Below is a production‑ready implementation that weighs less than 30 lines. It uses a Map of listener arrays keyed by the event’s type string, so lookup stays O(1).

class EventBus {
  private listeners = new Map void>>();

  /** Subscribe to a specific event type */
  on(
    type: K,
    handler: (payload: Extract['payload']) => void
  ): () => void {
    const set = this.listeners.get(type) ?? new Set();
    set.add(handler as (payload: any) => void);
    this.listeners.set(type, set);
    // Return an unsubscribe function
    return () => {
      set.delete(handler as (payload: any) => void);
      if (set.size === 0) this.listeners.delete(type);
    };
  }

  /** Emit an event — payload is checked against the union */
  emit(event: Extract): void {
    const set = this.listeners.get(event.type);
    if (!set) return;
    for (const fn of set) fn(event.payload);
  }
}

// Singleton instance for the whole app
export const bus = new EventBus();

Notice the on signature: the handler receives Extract['payload']. TypeScript resolves that to the exact payload shape for the given K. If you try to subscribe to 'user:login' with a handler expecting { message: string }, the compiler complains.

Real‑World Usage

In a React component that shows toast notifications, you only care about notification:show. The component subscribes on mount and cleans up on unmount:

function useToasts() {
  const [toasts, setToasts] = useState>([]);

  useEffect(() => {
    const unsubscribe = bus.on('notification:show', ({ message, level }) => {
      const id = Date.now();
      setToasts(prev => [...prev, { id, message, level }]);
      // Auto‑dismiss after 5 s
      setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 5000);
    });
    return unsubscribe;
  }, []);

  return toasts;
}

Because bus.on is typed, the destructured { message, level } is guaranteed to exist and have the correct types. No any casts, no runtime validation needed.

Emitting from Anywhere

A service that fetches user data can fire a login event without importing UI code:

async function login(credentials: Credentials) {
  const { userId, roles } = await api.authenticate(credentials);
  bus.emit({ type: 'user:login', payload: { userId, roles } });
  // Other listeners (analytics, feature flags) react automatically
}

If you later add a new field to the login payload, TypeScript will flag every emit call and every listener until you update them — a safety net that plain strings can’t provide.

Exhaustiveness Checks for Future‑Proofing

When you handle events in a central reducer or saga, a switch on event.type gives you compile‑time exhaustiveness:

function handleEvent(event: AppEvent) {
  switch (event.type) {
    case 'user:login':
      // event.payload is { userId: string; roles: string[] }
      break;
    case 'user:logout':
      // event.payload is { userId: string }
      break;
    case 'notification:show':
      // event.payload is { message: string; level: 'info' | 'warn' | 'error' }
      break;
    case 'data:refresh':
      // event.payload is { resource: string; params?: Record }
      break;
    default:
      const _exhaustive: never = event; // error if a new type is added
      break;
  }
}

Adding a new variant to AppEvent instantly produces a compile error in handleEvent, reminding you to update the logic.

When to Reach for This Pattern

  • Decoupled modules — feature packages, micro‑frontends, or plugins that shouldn’t import each other.
  • Cross‑cutting concerns — logging, analytics, feature‑flag updates, or UI notifications.
  • Testing — you can swap the bus with a test double that records emitted events, making integration tests trivial.

Tip: Keep the event union in a single events.ts file. It becomes the source of truth for the whole application’s contract.

Closing Thoughts

A typed event bus is a small investment that pays off every time a new feature needs to react to something happening elsewhere. The discriminated union gives you autocomplete, refactor‑safe payloads, and exhaustiveness checking — all without a heavyweight framework. Next time you reach for a global window.dispatchEvent or a loose Subject, consider spending ten minutes to wrap it in the pattern above. Your future self (and the CI pipeline) will thank you.