Skip to content

TypeScript Events

Custom events carry data through detail, but TypeScript doesn't know its shape automatically. This lesson covers patterns for giving your dispatched events real compile-time type safety.

Typing a CustomEvent's detail

CustomEvent is a generic type in TypeScript's DOM type definitions: CustomEvent<T>, where T is the shape of detail. Explicitly typing the events you dispatch (and, ideally, exporting a matching type alias) gives both your own component's code and any consumer's event listener accurate, checked access to event.detail's fields.

Without this, event.detail inside a listener is typed as any by default (from the base, generic CustomEvent type), silently losing all type safety exactly where a typo or shape mismatch is easy to miss.

export interface ItemSelectedDetail {
  id: string;
  index: number;
}

export type ItemSelectedEvent = CustomEvent<ItemSelectedDetail>;

class ItemList extends LitElement {
  #selectItem(id: string, index: number) {
    this.dispatchEvent(
      new CustomEvent('item-selected', {
        detail: { id, index },
        bubbles: true,
        composed: true,
      }) satisfies ItemSelectedEvent
    );
  }
}

The satisfies ItemSelectedEvent check confirms the constructed event actually matches the exported type, catching a mismatched detail shape at compile time.

Consuming a Typed Custom Event

import type { ItemSelectedEvent } from './item-list.js';

element.addEventListener('item-selected', (event: ItemSelectedEvent) => {
  console.log(event.detail.id); // fully typed, autocompletes correctly
});
  • Export both a detail interface and a CustomEvent<Detail> type alias for every custom event your component dispatches.
  • Consumers import the type alias and annotate their listener's parameter to get full autocomplete and type checking on event.detail.
  • addEventListener's built-in overloads won't automatically know about your custom event names — the explicit parameter annotation is what provides the typing.
  • The satisfies operator (TypeScript 4.9+) is a convenient way to verify a constructed event matches its expected type without changing the expression's inferred type.

Typed Events Cheat Sheet

The pattern to repeat for every custom event a component dispatches.

Step Code
1. Define the detail shape export interface FooDetail { ... }
2. Export an event type alias export type FooEvent = CustomEvent<FooDetail>;
3. Dispatch with a type check new CustomEvent('foo', { detail }) satisfies FooEvent
4. Consumer imports the type import type { FooEvent } from './my-el.js';
5. Consumer annotates the listener (event: FooEvent) => { ... }

Advanced: Augmenting HTMLElementEventMap

For an even more seamless experience, you can use TypeScript's declaration merging to add your custom event name directly to the global HTMLElementEventMap interface, so addEventListener('item-selected', ...) is typed correctly automatically, without the consumer needing an explicit type annotation at all.

declare global {
  interface HTMLElementEventMap {
    'item-selected': ItemSelectedEvent;
  }
}

// Now this is automatically typed, no annotation needed:
element.addEventListener('item-selected', (event) => {
  console.log(event.detail.id); // TypeScript already knows the shape
});

This technique is genuinely more ergonomic for consumers but adds complexity to your component library's own typings setup — reserve it for published, widely-consumed component libraries.

Typing Events Bound Inside Lit Templates

When listening for a custom event with @event-name=${handler} directly inside another Lit component's own template, TypeScript infers the parameter type from how you write the handler — annotate it explicitly with your exported event type for the same compile-time safety as a manually attached listener.

render() {
  return html`<item-list @item-selected=${this.#onItemSelected}></item-list>`;
}

#onItemSelected(event: ItemSelectedEvent) {
  console.log(event.detail.id);
}

Common Mistakes

  • Leaving event.detail typed as any by never specifying a generic type parameter for CustomEvent, losing type safety at the exact point it matters most.
  • Defining a detail interface but forgetting to export it, forcing consumers to redefine the same shape themselves.
  • Constructing a CustomEvent with a detail shape that doesn't actually match its declared type, without any compile-time check catching the mismatch.
  • Augmenting the global HTMLElementEventMap for internal, application-only components where the added complexity isn't worth the ergonomic benefit.

Key Takeaways

  • CustomEvent<T> is generic — always specify T (the detail shape) for events you dispatch.
  • Export both the detail interface and a matching event type alias for consumers to use.
  • The satisfies operator is a convenient way to verify a dispatched event matches its expected shape.
  • HTMLElementEventMap augmentation gives the most seamless typing experience, best reserved for published component libraries.

Pro Tip

Treat every custom event's detail type export with the same care as a public function's return type — it's the exact contract consumers will write code against, and a well-typed, well-named detail interface often serves as better documentation than a paragraph of prose ever could.