Type-Safe Event Handling
apps/docs/src/content/docs/components/typescript/event-types Click to copy 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
addEventListeneroverloads, thehx-blur/hx-navigate/hx-ready/hx-before-changeevent 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 augmentedGlobalEventHandlersEventMap-style declarations rather than per-element overloads, and components dispatch the events documented in their own source (e.g.hx-text-inputemitshx-input/hx-change, nothx-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.
Why Type-Safe Events Matter
Section titled “Why Type-Safe Events Matter”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 namebutton.addEventListener('click', (e) => { console.log(e.detail.value); // Runtime error: undefined});
// Compiles but fails at runtime: wrong detail propertybutton.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.
The CustomEvent Generic
Section titled “The CustomEvent Generic”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.
Defining Event Detail Interfaces
Section titled “Defining Event Detail Interfaces”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.
Basic Event Detail Types
Section titled “Basic Event Detail Types”// Simple detail with one propertyinterface HxClickDetail { originalEvent: MouseEvent;}
// Detail with multiple propertiesinterface HxChangeDetail { checked: boolean; value: string;}
// Detail with optional propertiesinterface HxInputDetail { value: string; isValid?: boolean;}
// Detail with no properties (marker event)interface HxReadyDetail {}// Or use `Record<string, never>` to enforce empty detailNaming convention: Use Hx<EventName>Detail to avoid name collisions and make the purpose obvious.
Real-World Example: hx-checkbox
Section titled “Real-World Example: hx-checkbox”From the hx-checkbox component source:
// Component property types@property({ type: Boolean, reflect: true })checked = false;
@property({ type: String })value = 'on';
// Event handlerprivate _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.
Dispatching Typed Events
Section titled “Dispatching Typed Events”Use CustomEvent<T> to type the event when dispatching. This ensures the detail object matches the interface at compile time.
Basic Typed Dispatch
Section titled “Basic Typed Dispatch”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 matchingHxClickDetail
Type Errors at Compile Time
Section titled “Type Errors at Compile Time”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.
Optional Detail Properties
Section titled “Optional Detail Properties”interface HxInputDetail { value: string; isValid?: boolean; // Optional}
// Valid: isValid is optionalthis.dispatchEvent( new CustomEvent<HxInputDetail>('hx-input', { bubbles: true, composed: true, detail: { value: this.value }, }),);
// Also valid: isValid can be providedthis.dispatchEvent( new CustomEvent<HxInputDetail>('hx-input', { bubbles: true, composed: true, detail: { value: this.value, isValid: this.checkValidity() }, }),);Events with No Detail
Section titled “Events with No Detail”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.
Type-Safe Event Listeners
Section titled “Type-Safe Event Listeners”Consumers of your components need type-safe event listeners. There are multiple approaches, each with trade-offs.
Approach 1: Manual Type Annotation
Section titled “Approach 1: Manual Type Annotation”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.
Step 1: Define the Event Map
Section titled “Step 1: Define the Event Map”interface HxButtonEventMap { 'hx-click': CustomEvent<HxClickDetail>;}Step 2: Overload addEventListener
Section titled “Step 2: Overload addEventListener”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); }}Step 3: Consumer Gets Type Inference
Section titled “Step 3: Consumer Gets Type Inference”const button = document.querySelector('hx-button')!;
// TypeScript infers event type from event namebutton.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.
Approach 4: oneEvent Helper for Tests
Section titled “Approach 4: oneEvent Helper for Tests”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 testsit('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 Map Patterns
Section titled “Event Map Patterns”Event maps centralize event type definitions and enable type-safe listener registration. Here are best practices for defining event maps.
Single-Event Components
Section titled “Single-Event Components”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); }}Multi-Event Components
Section titled “Multi-Event Components”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);});Extending Native Event Maps
Section titled “Extending Native Event Maps”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)});EventTarget Typing
Section titled “EventTarget Typing”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.
The EventTarget Interface
Section titled “The EventTarget Interface”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.
Narrowing EventTarget with Type Guards
Section titled “Narrowing EventTarget with Type Guards”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 }}Casting Event Targets
Section titled “Casting Event Targets”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.
Event Typing in Tests
Section titled “Event Typing in Tests”Type-safe events make tests more maintainable. Every event listener and detail assertion is validated at compile time.
Typed Event Assertions
Section titled “Typed Event Assertions”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.
Testing Event Bubbling and Composition
Section titled “Testing Event Bubbling and Composition”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.
JSDoc Annotations for CEM
Section titled “JSDoc Annotations for CEM”Custom Elements Manifest (CEM) generates API documentation from JSDoc comments. Use @event and @fires annotations to document events in a CEM-compatible format.
@event Annotation
Section titled “@event Annotation”/** * Dispatched when the button is clicked. * @event hx-click */this.dispatchEvent( new CustomEvent<HxClickDetail>('hx-click', { bubbles: true, composed: true, detail: { originalEvent: e }, }),);@fires Annotation (Component-Level)
Section titled “@fires Annotation (Component-Level)”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.
Advanced Event Patterns
Section titled “Advanced Event Patterns”Conditional Events
Section titled “Conditional Events”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 }});Event Composition (Retargeting)
Section titled “Event Composition (Retargeting)”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)});Generic Event Utilities
Section titled “Generic Event Utilities”Create reusable utilities for common event patterns:
// Type-safe event dispatcherexport 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);}
// UsagedispatchCustomEvent<HxClickDetail>(this, 'hx-click', { originalEvent: e,});Common Type Errors and Fixes
Section titled “Common Type Errors and Fixes”Error: Property does not exist on type ‘Event’
Section titled “Error: Property does not exist on type ‘Event’”// Bad: Event is not CustomEventbutton.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' propertythis.dispatchEvent( new CustomEvent<HxChangeDetail>('hx-change', { bubbles: true, composed: true, detail: { checked: this.checked }, // Error: Property 'value' is missing }),);
// Good: All properties providedthis.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 mapbutton.addEventListener('hx-invalid', (e) => { // Error: 'hx-invalid' is not in HxButtonEventMap});
// Good: Use valid event namebutton.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 assertionprivate _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 }}Real-World Example: hx-text-input
Section titled “Real-World Example: hx-text-input”Here’s a complete component demonstrating all event typing patterns:
import { LitElement, html } from 'lit';import { customElement, property, query } from 'lit/decorators.js';
// Detail interfacesinterface HxInputDetail { value: string;}
interface HxChangeDetail { value: string;}
// Event mapinterface 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 nameinput.addEventListener('hx-invalid', (e) => { // TypeScript error});Best Practices
Section titled “Best Practices”- Always type CustomEvent: Use
CustomEvent<T>when dispatching, neverCustomEvent(which defaults toany). - Define detail interfaces: Every custom event needs a corresponding
Detailinterface. - Use event maps for multi-event components: Centralize event types in a single
EventMapinterface. - Overload addEventListener: Provide type inference for consumers by overloading
addEventListenerandremoveEventListener. - Document events with JSDoc: Use
@firesand@eventannotations for CEM integration. - Test event details: Validate that detail payloads match their types in tests.
- Set bubbles and composed: Always set
bubbles: trueandcomposed: truefor custom events (unless you specifically need non-bubbling/non-composed behavior). - Use type guards for external targets: Use
instanceofchecks for event targets from unknown sources. - Use type assertions for internal targets: Safe to cast
e.target as Twhen you control the markup. - Avoid
anyin event maps: Never useanyfor event detail types. UseRecord<string, never>for empty details.
Resources
Section titled “Resources”- TypeScript Handbook: Generics
- TypeScript DOM Types
- Lit Events Documentation
- MDN: CustomEvent
- Custom Elements Manifest
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)