Skip to main content

On This Page

Type-Safe CustomEvents: Implementing Typed Native Event Messaging in TypeScript

3 min read
Share

These articles are AI-generated summaries. Please check the original sources for full details.

TypedEventTarget

The native EventTarget is a built-in browser API providing a fast, framework-agnostic messaging system. Standard CustomEvent interfaces default to an untyped detail property, frequently requiring manual type casting in TypeScript environments.

Why This Matters

While modern frameworks provide proprietary state and event management, they often introduce vendor lock-in and unnecessary runtime overhead. Native APIs like EventTarget offer superior performance and portability, but their lack of inherent type safety for event payloads can lead to fragile codebases; implementing a typed wrapper bridges the gap between native performance and developer productivity.

Key Insights

  • Standard CustomEvent interfaces require manual casting (e.g., ‘e as CustomEvent’) to access payload data safely.
  • TypedEventTarget utilizes a generic map (M extends Record<string, unknown>) to associate event names with specific data structures.
  • The implementation overrides addEventListener and removeEventListener to enforce TEL<M[K]> types while maintaining native compatibility.
  • A custom dispatchEvent helper automates the creation of CustomEvent objects, ensuring data payloads match the defined schema.
  • This pattern facilitates the ‘generalized singleton’ approach, allowing a single logic class to interact with multiple frameworks via native JS/TS.

Working Examples

Base class for creating a type-safe EventTarget wrapper.

type EventListener<E extends Event> = (evt: E) => void;
interface EventListenerObject<E extends Event> {
  handleEvent(evt: CustomEvent<E>): void;
}
type TEL<E> = EventListener<CustomEvent<E>> | EventListenerObject<CustomEvent<E>>;

export class TypedEventTarget<M extends Record<string, unknown>> {
  private readonly target = new EventTarget();

  addEventListener<K extends keyof M>(
    type: K & string,
    listener: TEL<M[K]>,
    options?: boolean | AddEventListenerOptions,
  ) {
    this.target.addEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  removeEventListener<K extends keyof M>(
    type: K & string,
    listener: TEL<M[K]>,
    options?: boolean | EventListenerOptions,
  ) {
    this.target.removeEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  dispatchEvent<K extends keyof M>(type: K, ...args: M[K] extends void ? [detail?: undefined] : [detail: M[K]]) {
    const [detail] = args;
    return this.target.dispatchEvent(new CustomEvent(String(type), { detail }));
  }
}

Real-world implementation of a shopping cart using the TypedEventTarget.

export interface CartItem {
  id: string;
  name: string;
  price: number;
}

type ShoppingCartEvents = {
  'item-added': CartItem;
  'item-removed': { id: string };
  'cart-cleared': void;
};

export default class ShoppingCart extends TypedEventTarget<ShoppingCartEvents> {
  private _items: CartItem[] = [];

  addItem(item: CartItem) {
    this._items.push(item);
    this.dispatchEvent('item-added', item);
  }

  removeItem(id: CartItem['id']) {
    this._items = this._items.filter((item) => item.id !== id);
    this.dispatchEvent('item-removed', { id });
  }
}

Practical Applications

  • Use case: Synchronizing shopping cart state across disparate UI components without framework-specific event emitters.
  • Pitfall: Using ‘any’ or manual casting for event ‘detail’ properties, which bypasses TypeScript safety and causes runtime errors during refactoring.

References:

Continue reading

Next article

9 AI Agents Building Products: Inside the reflectt-node Coordination System

Related Content