Skip to content
digital garden
Back to Blog

Building Type-Safe Event Systems with TypeScript Generics

10 min read
typescripteventsarchitecturepatterns

Every event system I've worked with has the same silent failure mode. You write emitter.on("user.craeted", handler) — note the typo — and nothing happens. No error, no warning, no squiggly red line. The handler just never fires, and you spend thirty minutes checking your business logic before you spot the misspelled string.

This is the fundamental problem with stringly-typed event systems. Node's EventEmitter, the DOM's EventTarget, most pub/sub libraries — they all accept arbitrary strings and any payloads. The compiler can't help you because it has no idea what events exist or what data they carry. TypeScript generics fix this completely. Let me show you how to build an event emitter where every event name, every payload shape, and every handler signature is validated at compile time.

The Problem: Stringly-Typed Events

Here's what a typical Node.js EventEmitter looks like in practice:

import { EventEmitter } from "events";
 
const emitter = new EventEmitter();
 
// No type checking on event names
emitter.on("user.created", (data) => {
  // data is `any` — no autocomplete, no safety
  console.log(data.emial); // typo in "email", no error
});
 
// No type checking on payloads
emitter.emit("user.created", { wrong: "shape" }); // no error
emitter.emit("user.craeted", { id: "1", email: "a@b.com" }); // typo, no error

Three bugs in five lines, and TypeScript catches zero of them. The event name is unchecked, the payload is any, and misspelled property access passes silently. This is the Observer pattern without a contract — and it falls apart in any codebase larger than a toy project. (If you want the full Observer pattern context, I covered it in the Behavioral Design Patterns article.)

The Foundation: Event Maps

The fix starts with a single type that maps every event name to its payload shape:

type AppEvents = {
  "user.created": { id: string; email: string; name: string };
  "user.deleted": { id: string };
  "order.placed": { orderId: string; total: number; currency: string };
  "order.shipped": { orderId: string; trackingNumber: string };
};

This is your single source of truth. Every event in your system is declared here with its exact payload type. If an event isn't in this map, it doesn't exist. If the payload doesn't match, the compiler rejects it. Simple.

Building the Typed Emitter

Let's implement a TypedEventEmitter<T> class that enforces the event map at every call site.

The Core Class

type EventMap = Record<string, unknown>;
type Handler<T> = (payload: T) => void;
 
class TypedEventEmitter<T extends EventMap> {
  private listeners = new Map<keyof T, Set<Handler<any>>>();
 
  on<K extends keyof T>(event: K, handler: Handler<T[K]>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler);
 
    // Return an unsubscribe function
    return () => this.off(event, handler);
  }
 
  off<K extends keyof T>(event: K, handler: Handler<T[K]>): void {
    this.listeners.get(event)?.delete(handler);
  }
 
  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners.get(event)?.forEach((handler) => handler(payload));
  }
}

The generic constraint K extends keyof T is doing all the work. When you call on("user.created", handler), TypeScript infers K as the literal type "user.created", looks up T[K] to get { id: string; email: string; name: string }, and enforces that the handler parameter matches that shape.

What the Compiler Catches Now

const emitter = new TypedEventEmitter<AppEvents>();
 
// Typo in event name — compile error
emitter.on("user.craeted", (data) => {});
// TS Error: Argument of type '"user.craeted"' is not assignable to
// parameter of type '"user.created" | "user.deleted" | "order.placed" | "order.shipped"'
 
// Wrong payload shape — compile error
emitter.emit("order.placed", { orderId: "123" });
// TS Error: Property 'total' is missing
// Property 'currency' is missing
 
// Correct usage — full autocomplete on `data`
emitter.on("user.created", (data) => {
  console.log(data.email); // autocomplete works, type is string
  console.log(data.emial); // compile error — property doesn't exist
});
 
// Unsubscribe via returned function
const unsub = emitter.on("order.placed", (data) => {
  console.log(`Order ${data.orderId}: $${data.total} ${data.currency}`);
});
unsub(); // clean unsubscribe, no need to keep handler reference

Every bug from the original example is now a compile-time error. You get autocomplete on event names and payload properties. Refactoring is safe — rename an event in the map and the compiler shows you every call site that needs updating.

Advanced Patterns

The basic emitter covers 80% of use cases. Here are patterns for the other 20%.

Wildcard Listeners

Sometimes you need a listener that fires on every event — logging, analytics, debugging. Add a special "*" key:

type WithWildcard<T extends EventMap> = T & {
  "*": { event: keyof T; payload: T[keyof T] };
};
 
class TypedEventEmitter<T extends EventMap> {
  private listeners = new Map<keyof T | "*", Set<Handler<any>>>();
 
  on<K extends keyof T>(event: K, handler: Handler<T[K]>): () => void;
  on(event: "*", handler: Handler<{ event: keyof T; payload: T[keyof T] }>): () => void;
  on(event: string, handler: Handler<any>): () => void {
    if (!this.listeners.has(event as keyof T)) {
      this.listeners.set(event as keyof T, new Set());
    }
    this.listeners.get(event as keyof T)!.add(handler);
    return () => this.listeners.get(event as keyof T)?.delete(handler);
  }
 
  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners.get(event)?.forEach((h) => h(payload));
    // Also notify wildcard listeners
    this.listeners.get("*" as keyof T)?.forEach((h) =>
      h({ event, payload })
    );
  }
}
 
// Usage
emitter.on("*", ({ event, payload }) => {
  console.log(`[${String(event)}]`, payload);
});

Once Listeners

Auto-remove after the first invocation:

once<K extends keyof T>(event: K, handler: Handler<T[K]>): () => void {
  const wrappedHandler: Handler<T[K]> = (payload) => {
    this.off(event, wrappedHandler);
    handler(payload);
  };
  return this.on(event, wrappedHandler);
}

Async Event Handlers with Error Boundaries

When handlers are async, you need to decide: fail fast or collect errors?

async emitAsync<K extends keyof T>(event: K, payload: T[K]): Promise<void> {
  const handlers = this.listeners.get(event);
  if (!handlers) return;
 
  const errors: Error[] = [];
 
  await Promise.all(
    [...handlers].map(async (handler) => {
      try {
        await handler(payload);
      } catch (err) {
        errors.push(err instanceof Error ? err : new Error(String(err)));
      }
    })
  );
 
  if (errors.length > 0) {
    throw new AggregateError(
      errors,
      `${errors.length} handler(s) failed for event "${String(event)}"`
    );
  }
}

This runs all handlers concurrently, collects failures, and throws an AggregateError if any failed. No single broken handler takes down the rest.

Namespaced Events with Template Literal Types

For large systems, you can use template literal types to enforce naming conventions:

type UserEvents = {
  [K in `user.${string}`]: K extends "user.created"
    ? { id: string; email: string }
    : K extends "user.deleted"
    ? { id: string }
    : never;
};
 
// More practical: define namespaces explicitly
type DomainEvents = {
  [K in `user.${  "created" | "updated" | "deleted"}`]: K extends "user.created"
    ? { id: string; email: string; name: string }
    : K extends "user.updated"
    ? { id: string; changes: Record<string, unknown> }
    : { id: string };
} & {
  [K in `order.${"placed" | "shipped" | "cancelled"}`]: K extends "order.placed"
    ? { orderId: string; total: number }
    : K extends "order.shipped"
    ? { orderId: string; trackingNumber: string }
    : { orderId: string; reason: string };
};

This gets verbose fast. In practice, I prefer the flat event map from earlier — it's more readable and achieves the same compile-time safety.

Real-World Example: Domain Events

Here's where this pattern really shines. Consider a user signup flow with multiple side effects: send a welcome email, create a Stripe customer, log an analytics event. Without an event system, you'd have a 200-line signup function coupling everything together.

With typed domain events, the signup handler emits a single event and walks away. This is the Mediator pattern at work — the emitter acts as a central hub that decouples producers from consumers.

// events.ts — the contract for your entire system
type DomainEvents = {
  "user.signup.completed": {
    userId: string;
    email: string;
    name: string;
    plan: "free" | "pro";
    referralCode?: string;
  };
  "user.email.verified": {
    userId: string;
    email: string;
  };
  "payment.subscription.created": {
    userId: string;
    stripeCustomerId: string;
    plan: "free" | "pro";
  };
  "notification.email.sent": {
    to: string;
    template: string;
    timestamp: number;
  };
};
 
// Create a singleton emitter
export const domainEvents = new TypedEventEmitter<DomainEvents>();
// auth.service.ts — the producer
async function handleSignup(input: SignupInput) {
  const user = await db.users.create({
    email: input.email,
    name: input.name,
    plan: input.plan,
  });
 
  // One emit, done. This function doesn't know or care
  // what happens next.
  domainEvents.emit("user.signup.completed", {
    userId: user.id,
    email: user.email,
    name: user.name,
    plan: input.plan,
    referralCode: input.referralCode,
  });
 
  return user;
}
// email.service.ts — consumer #1
domainEvents.on("user.signup.completed", async (data) => {
  await sendEmail({
    to: data.email, // fully typed, autocomplete works
    template: "welcome",
    variables: { name: data.name, plan: data.plan },
  });
});
 
// billing.service.ts — consumer #2
domainEvents.on("user.signup.completed", async (data) => {
  const customer = await stripe.customers.create({
    email: data.email,
    metadata: { userId: data.userId, plan: data.plan },
  });
 
  domainEvents.emit("payment.subscription.created", {
    userId: data.userId,
    stripeCustomerId: customer.id,
    plan: data.plan,
  });
});
 
// analytics.service.ts — consumer #3
domainEvents.on("user.signup.completed", (data) => {
  analytics.track("signup", {
    userId: data.userId,
    plan: data.plan,
    hasReferral: !!data.referralCode,
  });
});

The DomainEvents type is living documentation. Any developer can open events.ts and see every event in the system, what data it carries, and know that the compiler enforces all of it. Adding a new field to a payload? Change the type, and the compiler tells you every handler that needs updating.

React Integration

In React, event subscriptions need cleanup on unmount. A useEvent hook handles this:

import { useEffect, useRef } from "react";
 
function useEvent<
  T extends EventMap,
  K extends keyof T
>(
  emitter: TypedEventEmitter<T>,
  event: K,
  handler: Handler<T[K]>
): void {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;
 
  useEffect(() => {
    const listener: Handler<T[K]> = (payload) => {
      handlerRef.current(payload);
    };
 
    return emitter.on(event, listener); // on() returns unsubscribe
  }, [emitter, event]);
}

The handlerRef pattern avoids re-subscribing when the handler closure changes. The effect only re-runs if the emitter instance or event name changes. Unsubscription is automatic — the on() method returns an unsubscribe function, and useEffect calls it on cleanup.

function OrderNotifications() {
  const [lastOrder, setLastOrder] = useState<string | null>(null);
 
  useEvent(domainEvents, "order.placed", (data) => {
    setLastOrder(`Order ${data.orderId}: $${data.total}`);
  });
 
  // No cleanup code needed — the hook handles it
 
  return lastOrder ? <p>{lastOrder}</p> : null;
}

Comparison: Typed Emitter vs. Alternatives

FeatureTyped EmitterRxJSZustand subscriptionsNative EventTarget
Type safetyFull (event names + payloads)Full (per-Observable)Partial (selector types)None
Bundle size~0.5 KB~30 KB~2 KB0 KB (built-in)
Learning curveMinimalSteepLowMinimal
Async supportManual (shown above)Built-in (operators)N/ANone
BackpressureNoneBuilt-inN/ANone
MulticastingBuilt-inRequires share()/SubjectBuilt-inBuilt-in
UnsubscribeReturn functionSubscription.unsubscribe()Return functionremoveEventListener()
Best forDomain events, decouplingComplex async streamsUI state changesBrowser/DOM events

RxJS wins if you need operators like debounceTime, switchMap, or backpressure handling. Zustand wins if your events are really state changes. The typed emitter wins when you want a lightweight pub/sub contract that documents your system's event vocabulary with zero dependencies.

Takeaway

The full implementation is under 50 lines of code. The type safety comes entirely from TypeScript's generics and mapped types — no runtime cost, no dependencies, no code generation. Define your event map, constrain your methods with K extends keyof T, and let the compiler do the rest. Every string typo, every wrong payload, every missing field becomes a red squiggly line instead of a silent production bug.

Comments

No comments yet. Be the first to comment!