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);
});
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);
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);
});
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
});
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;
};
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>;
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;
}
}
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
});
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
});
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 {...}
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;
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)