Skip to content
HELiX

Type-Safe Event Handling

apps/docs/src/content/docs/components/typescript/event-types Click to copy
Copied! apps/docs/src/content/docs/components/typescript/event-types

Custom events are the primary communication mechanism between HELIX components and their consumers. Type-safe event handling transforms events from runtime stringly-typed messages into compile-time validated contracts. This guide demonstrates how to leverage TypeScript’s type system to make every event dispatch, listener, and detail payload fully typed and self-documenting.

Reading note: Several patterns below — per-component addEventListener overloads, the hx-blur / hx-navigate / hx-ready / hx-before-change event names, and the “complete source” sample blocks — are aspirational teaching examples, not the current shipped contract. The shipped HELiX events are documented in each component’s CEM (packages/hx-library/custom-elements.json); detail-payload typing is currently surfaced via globally augmented GlobalEventHandlersEventMap-style declarations rather than per-element overloads, and components dispatch the events documented in their own source (e.g. hx-text-input emits hx-input / hx-change, not hx-blur). Treat the recipes below as patterns to adapt; verify each event name and detail interface against CEM before relying on it in shipped code.

In enterprise healthcare applications, event handling errors can cascade into runtime failures that impact patient-facing software. Type-safe events provide:

  • Compile-time validation: Catch event name typos and incorrect detail payloads before they reach the browser
  • IDE autocomplete: IntelliSense for event names and detail properties
  • Refactoring safety: Rename events or change detail structures across the codebase with confidence
  • Self-documenting APIs: Event interfaces serve as living documentation
  • Consumer ergonomics: Consumers get full type inference when listening to your events

Without type safety, this code compiles but fails at runtime:

// Compiles but fails at runtime: wrong event name
button.addEventListener('click', (e) => {
console.log(e.detail.value); // Runtime error: undefined
});
// Compiles but fails at runtime: wrong detail property
button.addEventListener('hx-click', (e) => {
console.log(e.detail.value); // Runtime error: undefined (should be originalEvent)
});

With type safety, both errors are caught at compile time.

JavaScript’s CustomEvent constructor is generic over the detail payload type. TypeScript’s lib.dom.d.ts defines it as:

interface CustomEvent<T = any> extends Event {
readonly detail: T;
}
declare var CustomEvent: {
prototype: CustomEvent;
new <T>(typeArg: string, eventInitDict?: CustomEventInit<T>): CustomEvent<T>;
};

The generic parameter T types the detail property. By default, it is any, which bypasses type safety. To make events type-safe, you must explicitly provide the detail type.

Every custom event should have a corresponding TypeScript interface that describes its detail payload. This interface serves as the single source of truth for what data the event carries.

// Simple detail with one property
interface HxClickDetail {
originalEvent: MouseEvent;
}
// Detail with multiple properties
interface HxChangeDetail {
checked: boolean;
value: string;
}
// Detail with optional properties
interface HxInputDetail {
value: string;
isValid?: boolean;
}
// Detail with no properties (marker event)
interface HxReadyDetail {}
// Or use `Record<string, never>` to enforce empty detail

Naming convention: Use Hx<EventName>Detail to avoid name collisions and make the purpose obvious.

From the hx-checkbox component source:

// Component property types
@property({ type: Boolean, reflect: true })
checked = false;
@property({ type: String })
value = 'on';
// Event handler
private _handleChange(): void {
if (this.disabled) return;
this.checked = !this.checked;
/**
* Dispatched when the checkbox is toggled.
* @event hx-change
*/
this.dispatchEvent(
new CustomEvent('hx-change', {
bubbles: true,
composed: true,
detail: { checked: this.checked, value: this.value },
})
);
}

The corresponding detail interface should match the dispatched detail object:

interface HxChangeDetail {
checked: boolean;
value: string;
}

Rule: The detail interface must exactly match the object passed to detail in the event constructor.

Use CustomEvent<T> to type the event when dispatching. This ensures the detail object matches the interface at compile time.

interface HxClickDetail {
originalEvent: MouseEvent;
}
private _handleClick(e: MouseEvent): void {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
// Type-safe dispatch: TypeScript validates that detail matches HxClickDetail
this.dispatchEvent(
new CustomEvent<HxClickDetail>('hx-click', {
bubbles: true,
composed: true,
detail: { originalEvent: e },
})
);
}

Key points:

  • bubbles: true — Event propagates up the DOM tree (crosses component boundaries)
  • composed: true — Event crosses shadow DOM boundaries (escapes shadow roots)
  • detail — Typed payload matching HxClickDetail
interface HxChangeDetail {
checked: boolean;
value: string;
}
// Error: Type '{ cheked: boolean; value: string; }' is not assignable to type 'HxChangeDetail'
this.dispatchEvent(
new CustomEvent<HxChangeDetail>('hx-change', {
bubbles: true,
composed: true,
detail: { cheked: this.checked, value: this.value }, // Typo: 'cheked'
}),
);
// Error: Property 'value' is missing in type '{ checked: boolean; }'
this.dispatchEvent(
new CustomEvent<HxChangeDetail>('hx-change', {
bubbles: true,
composed: true,
detail: { checked: this.checked }, // Missing 'value'
}),
);

TypeScript catches these errors before they reach the browser.

interface HxInputDetail {
value: string;
isValid?: boolean; // Optional
}
// Valid: isValid is optional
this.dispatchEvent(
new CustomEvent<HxInputDetail>('hx-input', {
bubbles: true,
composed: true,
detail: { value: this.value },
}),
);
// Also valid: isValid can be provided
this.dispatchEvent(
new CustomEvent<HxInputDetail>('hx-input', {
bubbles: true,
composed: true,
detail: { value: this.value, isValid: this.checkValidity() },
}),
);

Some events are markers that carry no data. HELiX components that fall into this category — like hx-show / hx-reset / hx-close on overlays — dispatch CustomEvent<void> with no detail payload at all. The example below uses a hypothetical org-ready event because there is no hx-ready event in the shipped library:

// Preferred for HELiX: CustomEvent<void> with no `detail` field.
this.dispatchEvent(
new CustomEvent<void>('org-ready', {
bubbles: true,
composed: true,
}),
);
// Alternative when you want to forbid future fields with the type system.
type EmptyDetail = Record<string, never>;
this.dispatchEvent(
new CustomEvent<EmptyDetail>('org-ready', {
bubbles: true,
composed: true,
detail: {},
}),
);

Best practice: Match the shipped HELiX pattern — CustomEvent<void> with no detail field — when an event carries no data, so consumers don’t read e.detail on something that was never serialized.

Consumers of your components need type-safe event listeners. There are multiple approaches, each with trade-offs.

The simplest approach is to manually annotate the event parameter:

const button = document.querySelector('hx-button')!;
button.addEventListener('hx-click', (e: CustomEvent<HxClickDetail>) => {
// TypeScript knows e.detail is HxClickDetail
console.log(e.detail.originalEvent.clientX); // ✅ Type-safe
});

Pros: Simple, explicit, works everywhere.

Cons: Consumers must import the detail interface and manually annotate every listener.

Approach 2: Event Maps with addEventListener Overload

Section titled “Approach 2: Event Maps with addEventListener Overload”

Define an event map interface that maps event names to their CustomEvent<T> types, then overload addEventListener to provide type inference.

interface HxButtonEventMap {
'hx-click': CustomEvent<HxClickDetail>;
}
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
interface HxClickDetail {
originalEvent: MouseEvent;
}
interface HxButtonEventMap {
'hx-click': CustomEvent<HxClickDetail>;
}
@customElement('hx-button')
export class HelixButton extends LitElement {
// ... component properties and logic ...
// Overload addEventListener for type-safe event names and listeners
addEventListener<K extends keyof HxButtonEventMap>(
type: K,
listener: (this: HelixButton, ev: HxButtonEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener as EventListener, options);
}
// Also overload removeEventListener for symmetry
removeEventListener<K extends keyof HxButtonEventMap>(
type: K,
listener: (this: HelixButton, ev: HxButtonEventMap[K]) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void {
super.removeEventListener(type, listener as EventListener, options);
}
}
const button = document.querySelector('hx-button')!;
// TypeScript infers event type from event name
button.addEventListener('hx-click', (e) => {
// e is inferred as CustomEvent<HxClickDetail>
console.log(e.detail.originalEvent.clientX); // ✅ Type-safe, no manual annotation
});
// Error: Argument of type '"hx-invalid"' is not assignable to parameter of type '"hx-click"'
button.addEventListener('hx-invalid', (e) => {
// TypeScript error: 'hx-invalid' is not a valid event name
});

Pros: Full type inference, catches event name typos.

Cons: Requires consumers to have access to .d.ts files (works automatically with npm packages).

Approach 3: Inline Event Listeners in Lit Templates

Section titled “Approach 3: Inline Event Listeners in Lit Templates”

For internal event handling within Lit templates, use @event bindings with typed handler methods:

// Use a non-HELiX tag for the illustrative form below — the shipped hx-form
// has a different class shape; this snippet shows the event-typing pattern,
// not the real hx-form implementation.
@customElement('example-form')
export class HelixForm extends LitElement {
private _handleCheckboxChange(e: CustomEvent<HxChangeDetail>): void {
console.log(e.detail.checked, e.detail.value); // ✅ Type-safe
}
override render() {
return html`
<hx-checkbox label="Accept terms" @hx-change=${this._handleCheckboxChange}></hx-checkbox>
`;
}
}

Key: The handler method is explicitly typed with CustomEvent<T>. Lit’s @event binding passes the event to the handler, and TypeScript validates the parameter type.

For testing, use a typed oneEvent helper that returns a promise:

// Test utility (from hx-library/src/test-utils.ts)
export function oneEvent<T extends Event = Event>(
target: EventTarget,
eventName: string,
): Promise<T> {
return new Promise<T>((resolve) => {
const handler = (e: Event) => {
target.removeEventListener(eventName, handler);
resolve(e as T);
};
target.addEventListener(eventName, handler);
});
}
// Usage in tests
it('dispatches hx-change on toggle', async () => {
const el = await fixture<HelixCheckbox>('<hx-checkbox></hx-checkbox>');
const eventPromise = oneEvent<CustomEvent<HxChangeDetail>>(el, 'hx-change');
const control = shadowQuery<HTMLElement>(el, '.checkbox__control')!;
control.click();
const event = await eventPromise;
expect(event.detail.checked).toBe(true); // ✅ Type-safe
expect(event.detail.value).toBe('on'); // ✅ Type-safe
});

Benefit: Test code is fully typed, catches detail payload errors in tests.

Event maps centralize event type definitions and enable type-safe listener registration. Here are best practices for defining event maps.

For components with one custom event:

interface HxClickDetail {
originalEvent: MouseEvent;
}
interface HxButtonEventMap {
'hx-click': CustomEvent<HxClickDetail>;
}
@customElement('hx-button')
export class HelixButton extends LitElement {
addEventListener<K extends keyof HxButtonEventMap>(
type: K,
listener: (this: HelixButton, ev: HxButtonEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener as EventListener, options);
}
}

For components with multiple custom events:

interface HxInputDetail {
value: string;
}
interface HxChangeDetail {
value: string;
}
interface HxBlurDetail {
value: string;
}
// The shipped hx-text-input emits `hx-input` and `hx-change`, not `hx-blur`.
// Per-component `addEventListener` overloads are illustrative — the shipped
// library currently surfaces typed events via global `HTMLElementEventMap`
// augmentation rather than per-element overloads.
interface HxTextInputEventMap {
'hx-input': CustomEvent<HxTextInputDetail>;
'hx-change': CustomEvent<HxTextInputDetail>;
}
// Aspirational per-element overload pattern — adapt for your own components;
// shipped HELiX components don't currently expose this overload shape.
@customElement('example-typed-input')
export class ExampleTypedInput extends LitElement {
addEventListener<K extends keyof HxTextInputEventMap>(
type: K,
listener: (this: ExampleTypedInput, ev: HxTextInputEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener as EventListener, options);
}
}

With the global-event-map approach (current shipped pattern), consumers annotate the listener parameter explicitly instead:

import type { HxTextInputDetail } from '@helixui/library';
const input = document.querySelector('hx-text-input')!;
input.addEventListener('hx-input', (e: CustomEvent<HxTextInputDetail>) => {
console.log(e.detail.value);
});
input.addEventListener('hx-change', (e: CustomEvent<HxTextInputDetail>) => {
console.log(e.detail.value);
});

Components that emit both custom and native events (e.g., click, focus) can extend the event map to include both:

interface HxButtonEventMap extends HTMLElementEventMap {
'hx-click': CustomEvent<HxClickDetail>;
}
@customElement('hx-button')
export class HelixButton extends LitElement {
addEventListener<K extends keyof HxButtonEventMap>(
type: K,
listener: (this: HelixButton, ev: HxButtonEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener as EventListener, options);
}
}

Now consumers can listen to both native and custom events with type inference:

button.addEventListener('hx-click', (e) => {
// CustomEvent<HxClickDetail>
});
button.addEventListener('focus', (e) => {
// FocusEvent (from HTMLElementEventMap)
});

TypeScript’s DOM types include EventTarget, which is the base interface for all event-dispatching objects. Understanding EventTarget typing helps when working with event delegation or dynamically dispatched events.

interface EventTarget {
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions,
): void;
dispatchEvent(event: Event): boolean;
}

By default, EventTarget is untyped: type is string, listener receives Event, and dispatchEvent accepts any Event.

When handling events via delegation, use type guards to narrow EventTarget:

private _handleSlotChange(e: Event): void {
const target = e.target;
// Type guard: narrow EventTarget to HTMLSlotElement
if (target instanceof HTMLSlotElement) {
const assigned = target.assignedNodes({ flatten: true });
console.log(assigned.length); // ✅ Type-safe
}
}

For internal event handlers where you control the markup, casting is safe:

private _handleChange(e: Event): void {
const target = e.target as HTMLSelectElement;
this.value = target.value; // ✅ Safe: we know it's a select
this._internals.setFormValue(this.value);
}

Rule: Use type guards (instanceof) for external/unknown targets. Use casts (as) for internal/known targets.

Type-safe events make tests more maintainable. Every event listener and detail assertion is validated at compile time.

From hx-button.test.ts:

it('hx-click detail contains originalEvent', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent<HxClickDetail>>(el, 'hx-click');
btn.click();
const event = await eventPromise;
// TypeScript validates that detail.originalEvent exists and is a MouseEvent
expect(event.detail.originalEvent).toBeInstanceOf(MouseEvent);
});

From hx-checkbox.test.ts:

it('hx-change detail has checked and value', async () => {
const el = await fixture<HelixCheckbox>('<hx-checkbox value="agree"></hx-checkbox>');
const eventPromise = oneEvent<CustomEvent<HxChangeDetail>>(el, 'hx-change');
const control = shadowQuery<HTMLElement>(el, '.checkbox__control')!;
control.click();
const event = await eventPromise;
// TypeScript validates that detail.checked and detail.value exist
expect(event.detail.checked).toBe(true);
expect(event.detail.value).toBe('agree');
});

Benefit: If you refactor the event detail structure (e.g., rename checked to isChecked), TypeScript will flag every test that references the old property.

it('hx-click bubbles and is composed', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent<HxClickDetail>>(el, 'hx-click');
btn.click();
const event = await eventPromise;
// Validate event options
expect(event.bubbles).toBe(true);
expect(event.composed).toBe(true);
});

Rule: Always test that bubbles: true and composed: true are set for custom events. Without composed: true, events do not escape shadow DOM and cannot be caught by parent components.

Custom Elements Manifest (CEM) generates API documentation from JSDoc comments. Use @event and @fires annotations to document events in a CEM-compatible format.

/**
* Dispatched when the button is clicked.
* @event hx-click
*/
this.dispatchEvent(
new CustomEvent<HxClickDetail>('hx-click', {
bubbles: true,
composed: true,
detail: { originalEvent: e },
}),
);

At the component class level, use @fires to declare all events the component emits:

/**
* A text input component with label, validation, and form association.
*
* @summary Form-associated text input with built-in label, error, and help text.
*
* @tag hx-text-input
*
* @fires {CustomEvent<{value: string}>} hx-input - Dispatched on every keystroke as the user types.
* @fires {CustomEvent<{value: string}>} hx-change - Dispatched when the input loses focus after its value changed.
*
* @csspart input - The native input element.
*/
@customElement('hx-text-input')
export class HelixTextInput extends LitElement {
// ...
}

CEM output (from custom-elements.json):

{
"kind": "class",
"tagName": "hx-text-input",
"events": [
{
"name": "hx-input",
"type": {
"text": "CustomEvent<{value: string}>"
},
"description": "Dispatched on every keystroke as the user types."
},
{
"name": "hx-change",
"type": {
"text": "CustomEvent<{value: string}>"
},
"description": "Dispatched when the input loses focus after its value changed."
}
]
}

Storybook’s autodocs use this CEM metadata to generate event documentation automatically.

Dispatch different events based on component state:

private _handleClick(e: MouseEvent): void {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
// Note: shipped hx-button does NOT dispatch a separate `hx-navigate` event;
// it dispatches `hx-click` regardless of `href`. The split below is a
// hypothetical example for a consumer-owned button that distinguishes
// navigation activations from non-navigation activations.
if (this.href) {
// Hypothetical navigation event for org-button.
this.dispatchEvent(
new CustomEvent<HxNavigateDetail>('org-navigate', {
bubbles: true,
composed: true,
detail: { href: this.href, originalEvent: e },
})
);
} else {
// hx-click matches the real hx-button surface.
this.dispatchEvent(
new CustomEvent<HxClickDetail>('hx-click', {
bubbles: true,
composed: true,
detail: { originalEvent: e },
})
);
}
}

Event Cancellation (consumer-owned pattern)

Section titled “Event Cancellation (consumer-owned pattern)”

Allow consumers to cancel events using event.preventDefault(). Note that no shipped HELiX input component currently dispatches an hx-before-change event — the pattern below is illustrative for a consumer-owned input that wants to expose a cancelable pre-commit hook:

private _handleBeforeChange(newValue: string): boolean {
const event = new CustomEvent<OrgBeforeChangeDetail>('org-before-change', {
bubbles: true,
composed: true,
cancelable: true, // ✅ Allow preventDefault()
detail: { oldValue: this.value, newValue },
});
this.dispatchEvent(event);
// If consumer called preventDefault(), cancel the change
if (event.defaultPrevented) {
return false;
}
this.value = newValue;
return true;
}

Consumers can prevent the change:

orgInput.addEventListener('org-before-change', (e) => {
if (e.detail.newValue === 'invalid') {
e.preventDefault(); // Cancel the change
}
});

When events cross shadow DOM boundaries with composed: true, the event.target is retargeted to the host element. Use event.composedPath() to get the original target:

form.addEventListener('hx-change', (e) => {
console.log(e.target); // hx-checkbox (retargeted to host)
console.log(e.composedPath()[0]); // .checkbox__control (original target)
});

Create reusable utilities for common event patterns:

// Type-safe event dispatcher
export function dispatchCustomEvent<T>(
target: EventTarget,
eventName: string,
detail: T,
options: { bubbles?: boolean; composed?: boolean; cancelable?: boolean } = {},
): boolean {
const event = new CustomEvent<T>(eventName, {
bubbles: options.bubbles ?? true,
composed: options.composed ?? true,
cancelable: options.cancelable ?? false,
detail,
});
return target.dispatchEvent(event);
}
// Usage
dispatchCustomEvent<HxClickDetail>(this, 'hx-click', {
originalEvent: e,
});

Error: Property does not exist on type ‘Event’

Section titled “Error: Property does not exist on type ‘Event’”
// Bad: Event is not CustomEvent
button.addEventListener('hx-click', (e: Event) => {
console.log(e.detail.originalEvent); // Error: Property 'detail' does not exist on type 'Event'
});
// Good: Type as CustomEvent<T>
button.addEventListener('hx-click', (e: CustomEvent<HxClickDetail>) => {
console.log(e.detail.originalEvent); // ✅ Type-safe
});

Error: Type ’{}’ is missing the following properties

Section titled “Error: Type ’{}’ is missing the following properties”
interface HxChangeDetail {
checked: boolean;
value: string;
}
// Bad: Missing 'value' property
this.dispatchEvent(
new CustomEvent<HxChangeDetail>('hx-change', {
bubbles: true,
composed: true,
detail: { checked: this.checked }, // Error: Property 'value' is missing
}),
);
// Good: All properties provided
this.dispatchEvent(
new CustomEvent<HxChangeDetail>('hx-change', {
bubbles: true,
composed: true,
detail: { checked: this.checked, value: this.value },
}),
);

Error: Argument of type ‘string’ is not assignable to parameter of type ‘keyof EventMap’

Section titled “Error: Argument of type ‘string’ is not assignable to parameter of type ‘keyof EventMap’”
// Bad: Event name not in event map
button.addEventListener('hx-invalid', (e) => {
// Error: 'hx-invalid' is not in HxButtonEventMap
});
// Good: Use valid event name
button.addEventListener('hx-click', (e) => {
// ✅ Valid event name
});

Error: Type ‘EventTarget | null’ is not assignable to type ‘HTMLSelectElement’

Section titled “Error: Type ‘EventTarget | null’ is not assignable to type ‘HTMLSelectElement’”
// Bad: No null check or type assertion
private _handleChange(e: Event): void {
const target = e.target; // EventTarget | null
this.value = target.value; // Error: Property 'value' does not exist on type 'EventTarget'
}
// Good: Type assertion (safe for internal handlers)
private _handleChange(e: Event): void {
const target = e.target as HTMLSelectElement;
this.value = target.value; // ✅ Type-safe
}
// Also good: Type guard (safer for external handlers)
private _handleChange(e: Event): void {
if (e.target instanceof HTMLSelectElement) {
this.value = e.target.value; // ✅ Type-safe
}
}

Here’s a complete component demonstrating all event typing patterns:

import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
// Detail interfaces
interface HxInputDetail {
value: string;
}
interface HxChangeDetail {
value: string;
}
// Event map
interface HxTextInputEventMap {
'hx-input': CustomEvent<HxInputDetail>;
'hx-change': CustomEvent<HxChangeDetail>;
}
@customElement('hx-text-input')
export class HelixTextInput extends LitElement {
static formAssociated = true;
private _internals: ElementInternals;
constructor() {
super();
this._internals = this.attachInternals();
}
@property({ type: String })
value = '';
@query('.field__input')
private _input!: HTMLInputElement;
// Type-safe addEventListener overload
addEventListener<K extends keyof HxTextInputEventMap>(
type: K,
listener: (this: HelixTextInput, ev: HxTextInputEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener as EventListener, options);
}
// Type-safe removeEventListener overload
removeEventListener<K extends keyof HxTextInputEventMap>(
type: K,
listener: (this: HelixTextInput, ev: HxTextInputEventMap[K]) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void {
super.removeEventListener(type, listener as EventListener, options);
}
// Event handlers
private _handleInput(e: Event): void {
const target = e.target as HTMLInputElement;
this.value = target.value;
this._internals.setFormValue(this.value);
/**
* Dispatched on every keystroke as the user types.
* @event hx-input
*/
this.dispatchEvent(
new CustomEvent<HxInputDetail>('hx-input', {
bubbles: true,
composed: true,
detail: { value: this.value },
}),
);
}
private _handleChange(e: Event): void {
const target = e.target as HTMLInputElement;
this.value = target.value;
this._internals.setFormValue(this.value);
/**
* Dispatched when the input loses focus after its value changed.
* @event hx-change
*/
this.dispatchEvent(
new CustomEvent<HxChangeDetail>('hx-change', {
bubbles: true,
composed: true,
detail: { value: this.value },
}),
);
}
override render() {
return html`
<input
class="field__input"
type="text"
.value=${this.value}
@input=${this._handleInput}
@change=${this._handleChange}
/>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'hx-text-input': HelixTextInput;
}
}

Consumer usage:

const input = document.querySelector('hx-text-input')!;
// Type inference: e is CustomEvent<HxInputDetail>
input.addEventListener('hx-input', (e) => {
console.log(e.detail.value); // ✅ Type-safe
});
// Type inference: e is CustomEvent<HxChangeDetail>
input.addEventListener('hx-change', (e) => {
console.log(e.detail.value); // ✅ Type-safe
});
// Error: 'hx-invalid' is not a valid event name
input.addEventListener('hx-invalid', (e) => {
// TypeScript error
});
  1. Always type CustomEvent: Use CustomEvent<T> when dispatching, never CustomEvent (which defaults to any).
  2. Define detail interfaces: Every custom event needs a corresponding Detail interface.
  3. Use event maps for multi-event components: Centralize event types in a single EventMap interface.
  4. Overload addEventListener: Provide type inference for consumers by overloading addEventListener and removeEventListener.
  5. Document events with JSDoc: Use @fires and @event annotations for CEM integration.
  6. Test event details: Validate that detail payloads match their types in tests.
  7. Set bubbles and composed: Always set bubbles: true and composed: true for custom events (unless you specifically need non-bubbling/non-composed behavior).
  8. Use type guards for external targets: Use instanceof checks for event targets from unknown sources.
  9. Use type assertions for internal targets: Safe to cast e.target as T when you control the markup.
  10. Avoid any in event maps: Never use any for event detail types. Use Record<string, never> for empty details.

Related Pages:

  • Typing Lit Components — Property types, lifecycle methods, and template typing
  • Custom Events — Event fundamentals and dispatch patterns (prerequisite)
  • TypeScript Strict Mode — Enforcing strict type safety across the codebase
  • TypeScript Strict Mode — Strict compiler flags + type-safety patterns used across HELiX (the dedicated “Generics” guide on the previous draft never landed)