Building Type-Safe Event Systems with TypeScript Generics
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 errorThree 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 referenceEvery 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
| Feature | Typed Emitter | RxJS | Zustand subscriptions | Native EventTarget |
|---|---|---|---|---|
| Type safety | Full (event names + payloads) | Full (per-Observable) | Partial (selector types) | None |
| Bundle size | ~0.5 KB | ~30 KB | ~2 KB | 0 KB (built-in) |
| Learning curve | Minimal | Steep | Low | Minimal |
| Async support | Manual (shown above) | Built-in (operators) | N/A | None |
| Backpressure | None | Built-in | N/A | None |
| Multicasting | Built-in | Requires share()/Subject | Built-in | Built-in |
| Unsubscribe | Return function | Subscription.unsubscribe() | Return function | removeEventListener() |
| Best for | Domain events, decoupling | Complex async streams | UI state changes | Browser/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!