DEV Community

Cover image for Creating Strongly Typed Events for Web Components
Burton Smith
Burton Smith

Posted on

Creating Strongly Typed Events for Web Components

If you've ever tried to handle custom events from web components in TypeScript, you've probably run into this frustrating error:

const myElement = document.querySelector('my-element');

myElement.addEventListener('my-event', e => {
  // ❌ Property 'items' does not exist on type 'EventTarget'
  console.log(e.target.items);
});
Enter fullscreen mode Exit fullscreen mode

This article explores how to implement strongly typed custom events that enhance the developer experience while maintaining flexibility for non-TypeScript users.

What You'll Learn

  • Fix TypeScript errors with custom events (5 minutes)
  • Set up automatic type inference for your events
  • Provide better event integration with React, Vue, and Angular
  • Write self-documenting event code

The Problem: Weakly Typed Event Information

When working with custom events in web components, developers frequently need to access properties and methods from the element that emitted the event by accessing the event target. However, TypeScript doesn't automatically infer the correct type, leading to frustrating scenarios where type safety is lost at critical integration points.

Consider this to-do list example:

export type TodoItem = {
  id: string;
  text: string;
  completed: boolean;
}

class TodoList extends HTMLElement {
  private _items: TodoItem[] = [];

  connectedCallback() {
    this.dispatchEvent(new CustomEvent('todo-ready'));
  }

  addItem(text: string) {
    const item: TodoItem = {
      id: crypto.randomUUID(),
      text,
      completed: false
    };
    this._items.push(item);

    this.dispatchEvent(new CustomEvent('todo-added', {
      bubbles: true,
      detail: { item, total: this.items.length }
    }));
  }

  toggleItem(id: string) {
    const item = this.items.find(i => i.id === id);
    if (item) {
      item.completed = !item.completed;
      this.dispatchEvent(new CustomEvent('todo-toggled', {
        bubbles: true,
        detail: { item, total: this.items.length }
      }));
    }
  }

  get items(): TodoItem[] {
    return this._items;
  }
}

customElements.define('todo-list', TodoList);
Enter fullscreen mode Exit fullscreen mode

When consuming this component, developers encounter type safety issues:

const todoList = document.querySelector('todo-list');
todoList?.addEventListener('todo-added', (e) => {
  // ❌ Error: Property 'items' does not exist on type 'EventTarget'
  console.log(e.target.items);

  // ❌ Error: Property 'total' does not exist on type 'unknown'
  console.log(e.detail.total);
});
Enter fullscreen mode Exit fullscreen mode

The typical workaround involves manual type assertions, which are verbose and error-prone:

todoList?.addEventListener('todo-added', (e) => {
  // ⚠️ Requires type assertions
  const target = e.target as TodoList; 
  const detail = e.detail as { item: TodoItem; total: number };

  console.log(target.items); // ⚠️ Works, but fragile
  console.log(detail.total); // ⚠️ Requires knowledge of internal structure
});
Enter fullscreen mode Exit fullscreen mode

This approach creates several problems for library authors:

  • Poor Developer Experience: Users must remember to cast types manually
  • Documentation Burden: You must document event structures separately
  • Maintenance Overhead: Type mismatches cause runtime errors

The Solution: Strongly Typed Events

The solution involves creating a generic type system that preserves both event target types and custom event detail types. This approach provides compile-time safety without any runtime overhead.

/**
 * A generic type for strongly typing custom events with their targets
 * @template T - The type of the event target (extends EventTarget)
 * @template D - The type of the detail payload for the custom event
 */
export type TypedEvent<
  T extends EventTarget,
  D = unknown
> = CustomEvent<D> & {
  target: T;
};
Enter fullscreen mode Exit fullscreen mode

Defining Component Event Types

For our TodoList component, we can create strongly typed event definitions:

/** Event detail type definition when the to-do list changes */
export type TodoChangeDetail = {
  /** The item that was added or changed */
  item: TodoItem;
  /** The total number of to-do items */
  total: number;
}

/** `TodoList` specific generic event type */
export type TodoListEvent<D = unknown> = TypedEvent<TodoList, D>;

/** The type for the change event */
export type TodoListChangeEvent = TodoListEvent<TodoChangeDetail>;
Enter fullscreen mode Exit fullscreen mode

Global Event Map Registration

To enable automatic type inference with addEventListener, register your custom events in the global event map:

declare global {
  interface GlobalEventHandlersEventMap {
    'todo-ready': TodoListEvent;
    'todo-added': TodoListChangeEvent;
    'todo-toggled': TodoListChangeEvent;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now developers can use your events with full type safety:

const todoList = document.querySelector('todo-list');

todoList?.addEventListener('todo-added', (e) => {
  // ✅ Fully typed - no assertions needed
  console.log(e.target.items); // TodoItem[]
  console.log(e.detail.total); // number
  console.log(e.detail.item.text); // string
});

todoList?.addEventListener('todo-toggled', (e) => {
  // ✅ Different detail type automatically inferred
  console.log(e.detail.item.completed); // boolean
});
Enter fullscreen mode Exit fullscreen mode

Non-unique Event Names

When using standard event names like change or input, you cannot extend the global event map due to conflicts. Instead, provide typed overloads:

// For standard events, provide explicit typing
const todoList = document.querySelector('todo-list');

todoList?.addEventListener('change', (e: TodoListChangeEvent) => {
  console.log(e.target.items); // ✅ Strongly typed
});
Enter fullscreen mode Exit fullscreen mode

Documenting Events

To document these custom events, we can use the @event or @fires JSDoc tags in the component class's documentation. In the description, we can also provide a type. This information will all be added to the Custom Element's Manifest. This is very helpful when creating custom integrations and type definitions for various environments.

/**
 * ... other documentation
 *
 * @event {TodoListEvent} todo-ready - emitted when the component is mounted
 * @event {TodoListChangeEvent} todo-added - emitted when a to-do item is added
 * @event {TodoListChangeEvent} todo-toggled - emitted when a to-do item is toggled
 */
class TodoList extends HTMLElement {...}
Enter fullscreen mode Exit fullscreen mode

Demo

If you would like to play around with these types yourself, feel free to check out this StackBlitz.

Usage in Frameworks

Strongly typing these events can also make framework integration with your web components much easier. Here is a simple react example:

import React, { useState } from 'react';
import { TodoListChangeEvent, TodoItem } from './todo-list-types';

const TodoApp = () => {
  const [items, setItems] = useState<TodoItem[]>([]);
  const [total, setTotal] = useState(0);

  const handleTodoAdded = (event: TodoListChangeEvent) => {
    setItems(event.target.items);
    setTotal(event.detail.total);
  };

  const handleTodoToggled = (event: TodoListChangeEvent) => {
    setItems(event.target.items);
  };

  return (
    <div>
      <todo-list ontodo-added={handleTodoAdded} ontodo-toggled={handleTodoToggled}></todo-list>
      <p>Total items: {total}</p>
      <ul>
        {items.map(item => (
          <li key={item.id} style={{ 
            textDecoration: item.completed ? 'line-through' : 'none' 
          }}>
            {item.text}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoApp;
Enter fullscreen mode Exit fullscreen mode

Benefits of Strongly Typed Custom Events

This pattern provides several key advantages:

  • Compile-time Safety: TypeScript catches mismatched property names and types before your code runs, preventing common runtime errors.
  • Enhanced Developer Experience: IDE autocomplete works perfectly, showing available properties on both the event target and detail object.
  • Self-Documenting Code: The type definitions serve as documentation, making it clear what data events are carried and which elements can dispatch them.
  • Refactoring Confidence: When you change event data structures, TypeScript will flag all locations that need updates.
  • Better Testing: Strongly typed events make it easier to write comprehensive tests since you know exactly what data to expect.

Conclusion

Strongly typing custom events transforms them from a potential source of bugs into a reliable, developer-friendly communication mechanism. By leveraging TypeScript's type system with patterns like the TypedEvent we created, you can build more maintainable applications with better developer experience and fewer runtime surprises.

Top comments (0)