Why a Typed Event Emitter Matters

In a recent project I needed a lightweight way to communicate between loosely coupled modules without pulling in a full‑blown library. The classic EventEmitter from Node works, but it loses all type information the moment you call emit or on. That means runtime bugs that only surface when a payload shape changes.

TypeScript’s discriminated unions give us a way to keep the compiler in the loop. By defining a union of event types where each member carries a literal type field, the compiler can narrow the payload automatically. The result is an emitter that refuses to compile if you try to listen for an event that doesn’t exist or pass the wrong payload.

The Core Types

// Event map – add new events here and the rest of the code adapts automatically
export interface EventMap {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string; reason?: string };
  "order:created": { orderId: string; amount: number; currency: "USD" | "EUR" };
  "notification:push": { channel: "email" | "sms" | "push"; payload: unknown };
}

// Discriminated union used internally
export type EventUnion = {
  [K in keyof EventMap]: { type: K; payload: EventMap[K] };
}[keyof EventMap];

The EventMap is the single source of truth. Every new event is added once, and the union EventUnion is derived automatically. The literal type field is the discriminant that TypeScript uses for narrowing.

The Emitter Implementation

export class TypedEmitter {
  // Map of event type → set of listeners
  private listeners = new Map void>>();

  /** Subscribe to a specific event */
  on(
    event: K,
    handler: (payload: EventMap[K]) => void
  ): () => void {
    const set = this.listeners.get(event) ?? new Set();
    set.add(handler);
    this.listeners.set(event, set);

    // Return an unsubscribe function – handy for React useEffect cleanup
    return () => {
      set.delete(handler);
      if (set.size === 0) this.listeners.delete(event);
    };
  }

  /** Emit an event – payload is fully typed */
  emit(event: K, payload: EventMap[K]): void {
    const set = this.listeners.get(event);
    if (!set) return; // no listeners, nothing to do
    for (const handler of set) {
      try {
        handler(payload);
      } catch (err) {
        // Prevent one bad listener from breaking the rest
        console.error(`Listener for ${event} threw:`, err);
      }
    }
  }

  /** Emit using the discriminated union – useful when the event type is dynamic */
  emitUnion(event: EventUnion): void {
    this.emit(event.type, event.payload);
  }
}

Notice the generic on and emit methods. They constrain K to keys of EventMap, so the payload type is inferred automatically. The emitUnion helper lets you forward a union value you might have received from a message bus or a worker thread.

Real‑World Usage

Imagine a checkout flow where a payment service, an inventory service, and a notification service all need to react to an order creation. With the typed emitter each service imports the same EventMap and registers only the events it cares about.

// orderService.ts
import { TypedEmitter, EventMap } from "./typed-emitter";

export const orderEmitter = new TypedEmitter();

export function createOrder(orderId: string, amount: number, currency: "USD" | "EUR") {
  // … business logic …
  orderEmitter.emit("order:created", { orderId, amount, currency });
}
// notificationService.ts
import { orderEmitter } from "./orderService";

orderEmitter.on("order:created", ({ orderId, amount, currency }) => {
  // TypeScript knows the exact shape – no casting needed
  sendPush({ channel: "push", payload: { orderId, amount, currency } });
});

If someone later adds a field to order:created in EventMap, both the emitter call and the listener will immediately show a compile‑time error until the handler is updated. That safety net has saved me countless hours during refactors.

Tip: Keep the EventMap in a shared package (e.g., @myorg/events) so front‑end and back‑end stay in sync without duplicating types.

Exhaustiveness Checking for Dynamic Handlers

Sometimes you need a single function that handles *all* events — for logging, analytics, or a dev‑tools panel. TypeScript can verify you didn’t forget a case:

function logEvent(event: EventUnion): void {
  switch (event.type) {
    case "user:login":
      console.log(`User ${event.payload.userId} logged in`);
      break;
    case "user:logout":
      console.log(`User ${event.payload.userId} logged out`);
      break;
    case "order:created":
      console.log(`Order ${event.payload.orderId} for ${event.payload.amount} ${event.payload.currency}`);
      break;
    case "notification:push":
      console.log(`Push via ${event.payload.channel}`);
      break;
    default:
      // If a new event is added and you forget a case, TS errors here
      const _exhaustive: never = event;
      return _exhaustive;
  }
}

The default branch with never forces the compiler to ensure every union member is handled. It’s a small pattern that pays off big when the event list grows.

Performance Considerations

  • Listener storage: A Map of Set gives O(1) add/remove and fast iteration.
  • Memory: Unsubscribe functions clean up automatically; no leaky globals.
  • Sync vs async: The emitter is synchronous by design. If you need async listeners, wrap the call in Promise.resolve().then(...) or use a separate async queue.

In practice the overhead is negligible — a few microseconds per emit — and the type safety far outweighs the cost.

Extending the Pattern

You can layer additional features without breaking the core idea:

  1. Middleware: Wrap emit to add logging, metrics, or retry logic.
  2. Namespacing: Prefix event keys ("billing:invoice:paid") to avoid collisions across domains.
  3. Serialization: Because each payload is a plain object, you can safely JSON.stringify the union for cross‑process messaging.

I’ve used this exact emitter in a micro‑frontend architecture where each app registers its own event slice. The shared EventMap lives in a monorepo package, so a change in one app immediately surfaces as a TypeScript error in the others — catching contract violations before they hit production.

Final Thoughts

Discriminated unions turn TypeScript from a “type checker” into a design‑time contract enforcer. By centering the event definitions in a single map and letting the compiler derive the union, you get autocompletion, refactor safety, and exhaustive handling for free. The boilerplate is tiny, the runtime cost is minimal, and the confidence boost is huge — especially when the system grows and multiple teams touch the same event bus.

Give it a try on your next feature that needs decoupled communication. You’ll wonder how you ever lived without compile‑time event safety.