Building Type-Safe Event Systems in TypeScript
Stringly-typed event emitters are a bug magnet. Learn how to build a fully type-safe event system using TypeScript generics, mapped types, and discriminated unions.
Event-driven architecture is everywhere in frontend development — DOM events, state management, WebSocket messages, server-sent events. Yet most event systems in TypeScript are essentially string → any maps with no compile-time safety. A typo in an event name or a wrong payload shape silently passes the compiler and explodes at runtime. Let's fix that.
The Problem: Stringly-Typed Events
Here's what most event emitters look like in practice:
// The typical untyped approach
class EventBus {
private listeners = new Map<string, Set<Function>>();
on(event: string, handler: Function) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(handler);
}
emit(event: string, payload?: unknown) {
this.listeners.get(event)?.forEach((fn) => fn(payload));
}
}
const bus = new EventBus();
bus.on("user:logn", (data) => { /* typo — no error! */ });
bus.emit("user:login", { wrong: "shape" }); // wrong payload — no error!
Zero type safety. The compiler is blind to event names and payload shapes.
Step 1: Define Your Event Map
The foundation is a type that maps event names to their payload types:
// Define all events and their payloads in one place
interface AppEventMap {
"user:login": { userId: string; email: string; timestamp: number };
"user:logout": { userId: string };
"post:created": { postId: string; title: string; authorId: string };
"post:liked": { postId: string; userId: string };
"notification:new": { id: string; message: string; type: "info" | "warning" | "error" };
"theme:changed": { theme: "light" | "dark" };
}
// Now build a type-safe emitter around this map
class TypedEventEmitter<TEvents extends Record<string, unknown>> {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof TEvents & string>(
event: K,
handler: (payload: TEvents[K]) => void
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const handlers = this.listeners.get(event)!;
handlers.add(handler);
// Return unsubscribe function
return () => handlers.delete(handler);
}
emit<K extends keyof TEvents & string>(event: K, payload: TEvents[K]): void {
this.listeners.get(event)?.forEach((fn) => fn(payload));
}
off<K extends keyof TEvents & string>(
event: K,
handler: (payload: TEvents[K]) => void
): void {
this.listeners.get(event)?.delete(handler);
}
}
// Usage — fully type-safe!
const events = new TypedEventEmitter<AppEventMap>();
events.on("user:login", ({ userId, email, timestamp }) => {
// All three params are typed ✅
console.log(`${email} logged in at ${new Date(timestamp)}`);
});
events.emit("user:login", {
userId: "123",
email: "dev@example.com",
timestamp: Date.now(),
}); // ✅
events.emit("user:login", { wrong: true }); // ❌ Type error!
events.on("user:logn", () => {}); // ❌ Type error — typo caught!
Step 2: React Hook Integration
Wrap the emitter in a React hook that handles subscription cleanup automatically:
"use client";
import { useEffect, useRef } from "react";
// Singleton event bus
const globalEvents = new TypedEventEmitter<AppEventMap>();
export function useEvent<K extends keyof AppEventMap & string>(
event: K,
handler: (payload: AppEventMap[K]) => void
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const unsubscribe = globalEvents.on(event, (payload) => {
handlerRef.current(payload);
});
return unsubscribe;
}, [event]);
}
export function emitEvent<K extends keyof AppEventMap & string>(
event: K,
payload: AppEventMap[K]
): void {
globalEvents.emit(event, payload);
}
// Usage in components
function NotificationBell() {
const [count, setCount] = useState(0);
useEvent("notification:new", (notification) => {
// notification is fully typed: { id, message, type }
setCount((c) => c + 1);
if (notification.type === "error") {
toast.error(notification.message);
}
});
return <Bell count={count} />;
}
function ThemeToggle() {
const handleToggle = (isDark: boolean) => {
emitEvent("theme:changed", { theme: isDark ? "dark" : "light" });
};
return <Toggle onChange={handleToggle} />;
}
Step 3: Wildcard and Namespace Patterns
Add namespace support to listen to groups of events using template literal types:
type EventNamespace<T extends Record<string, unknown>> = {
[K in keyof T & string as K extends `${infer NS}:${string}` ? NS : never]: {
[E in keyof T & string as E extends `${infer N}:${infer Sub}`
? N extends (K extends `${infer NS}:${string}` ? NS : never) ? Sub : never
: never]: T[E];
};
};
// Extract all events under a namespace
type UserEvents = {
[K in keyof AppEventMap & string as K extends `user:${infer Sub}` ? Sub : never]: AppEventMap[K];
};
// { login: { userId, email, timestamp }, logout: { userId } }
Takeaways
- Define an event map interface as the single source of truth for all event names and payloads
- Use generics with
keyofconstraints to enforce type safety onon()andemit() - Return unsubscribe functions from
on()for clean React hook integration - Use
useReffor handler references to avoid stale closures in React effects - Template literal types enable namespace-based event grouping at the type level
A type-safe event system catches bugs at compile time that would otherwise only surface in production. The upfront investment in typing your event map pays for itself the first time a teammate renames an event and the compiler highlights every callsite that needs updating.
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!