Bỏ qua để vào nội dung chính
Building Type-Safe Event Systems in TypeScript

Building Type-Safe Event Systems in TypeScript

Bởi Charlotte Adams
03 thg 3, 20267 phút đọc

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: "[email protected]",
  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 keyof constraints to enforce type safety on on() and emit()
  • Return unsubscribe functions from on() for clean React hook integration
  • Use useRef for 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan