From MouseEvent to a typed event emitter
Writing event handlers in TypeScript with any or the generic Event type throws away autocomplete, risks runtime errors from invalid property access, and produces bugs that only surface in the browser. This article shows how to correctly type MouseEvent, KeyboardEvent, and CustomEvent, how to cleanly narrow target and currentTarget, and how a type-safe event emitter robustly connects components in Magento and Hyvae frontends.
Table of Contents
- 1. Capturing DOM events type-safely: Event, MouseEvent, KeyboardEvent, PointerEvent
- 2. Typing addEventListener callbacks and understanding overload resolution
- 3. Cleanly narrowing event.target and event.currentTarget
- 4. Typing custom events with CustomEvent<T>
- 5. Typed event emitters for component communication
- 6. Typing events in Alpine.js components
- 7. Footguns: any, incorrect this, removeEventListener mismatches
- 8. Delegated events with type narrowing
- 9. Untyped vs. typed compared side by side
- 10. Summary
- 11. FAQ
1. Capturing DOM events type-safely: Event, MouseEvent, KeyboardEvent, PointerEvent
In TypeScript's DOM type definitions, Event is the base interface for every event dispatched in the browser, yet most practically relevant events carry additional properties that don't exist on the base class at all: MouseEvent.clientX, KeyboardEvent.key, or PointerEvent.pointerType. Declaring a handler as (event: Event) => void compiles just fine, but every access to a more specific property then forces a manual cast, which undoes the very advantage TypeScript is supposed to provide. The correct approach is to always use the concrete subtype that matches the event name.
The real strength lies in HTMLElementEventMap and the related maps such as DocumentEventMap and WindowEventMap from lib.dom.d.ts. These interfaces link every known event name as a string literal to its concrete event type, so that addEventListener automatically infers the matching callback type when given a literal string argument, with no manual type annotation required. Passing the event name as a generic string variable instead loses this inference entirely and falls back to the unspecific Event type.
// TypeScript infers the correct Event subtype from the literal event name
const button = document.querySelector<HTMLButtonElement>('#submit');
button?.addEventListener('click', (event: MouseEvent) => {
// event.clientX/clientY only exist on MouseEvent, not on the base Event type
console.log(`Clicked at ${event.clientX}, ${event.clientY}`);
});
document.addEventListener('keydown', (event: KeyboardEvent) => {
// event.key only exists on KeyboardEvent
if (event.key === 'Escape') {
closeModal();
}
});
const surface = document.querySelector<HTMLDivElement>('#drag-surface');
surface?.addEventListener('pointermove', (event: PointerEvent) => {
// event.pointerType distinguishes mouse, pen, and touch input
if (event.pointerType === 'touch') {
handleTouchDrag(event.clientX, event.clientY);
}
});
function closeModal(): void {}
function handleTouchDrag(x: number, y: number): void {}
2. Typing addEventListener callbacks and understanding overload resolution
addEventListener is defined in lib.dom.d.ts as an overloaded function: a generic overload with K extends keyof HTMLElementEventMap kicks in whenever the event name is a known string literal, while a second, generic fallback overload takes over once the name can't be uniquely resolved. This overload resolution is exactly why element.addEventListener('click', handler) automatically infers MouseEvent, whereas element.addEventListener(dynamicName, handler) falls back to the generic Event type as soon as dynamicName has the type string instead of a literal type.
For reusable code, a generic wrapper that explicitly enforces overload resolution instead of leaving it to the caller pays off. A function with the signature <K extends keyof HTMLElementEventMap>(element, type: K, listener: (event: HTMLElementEventMap[K]) => void) passes on the same type safety while also preventing typos in the event name, since TypeScript flags every non-existent event name as a compile error. This pattern is especially useful for utility libraries shared across many components.
// Generic wrapper that preserves addEventListener's overload resolution
function onTyped<K extends keyof HTMLElementEventMap>(
element: HTMLElement,
type: K,
listener: (event: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions
): void {
element.addEventListener(type, listener, options);
}
const input = document.querySelector<HTMLInputElement>('#search');
if (input) {
// "input" event resolves to InputEvent, no manual cast needed
onTyped(input, 'input', (event) => {
console.log(event.data);
});
// Passing a non-existent event name is now a compile-time error
// onTyped(input, 'not-an-event', (event) => {});
}
3. Cleanly narrowing event.target and event.currentTarget
event.target and event.currentTarget are both declared in TypeScript with the broad type EventTarget | null, regardless of which concrete element actually triggered the handler. The difference between the two matters a great deal in practice: currentTarget is always exactly the element the listener was attached to, while target is the deepest element in the event chain, meaning that a click on a nested <span> inside a button reports that span, not the button itself.
Because EventTarget lacks DOM-specific properties like closest, dataset, or value, a type guard is unavoidable. The safest approach is instanceof HTMLElement or a more specific type such as instanceof HTMLInputElement, since the compiler then knows the narrower type inside the if block. A blind cast with as HTMLElement also compiles, but it silently pushes errors from incorrect assumptions into runtime, for instance when target actually turns out to be a text node instead of an element.
// event.target vs event.currentTarget: two different types with different pitfalls
const list = document.querySelector<HTMLUListElement>('#product-list');
list?.addEventListener('click', (event: MouseEvent) => {
// currentTarget is always the element the listener is bound to, but its
// static type is EventTarget | null, so a cast is unavoidable here
const currentTarget = event.currentTarget as HTMLUListElement;
// target is the deepest element that triggered the event and needs a
// runtime check, since it could be any descendant node
const target = event.target;
if (target instanceof HTMLElement) {
const item = target.closest<HTMLLIElement>('li[data-product-id]');
if (item) {
selectProduct(item.dataset.productId ?? '');
}
}
});
function selectProduct(id: string): void {}
4. Typing custom events with CustomEvent<T>
CustomEvent is generic over the type of its detail property in TypeScript: CustomEvent<T>. If no explicit type parameter is provided when it's created, TypeScript infers detail as any, which removes all type safety at exactly the point where custom components typically exchange the most data. The simplest fix is to specify the payload type explicitly at the constructor call, for example new CustomEvent<CartUpdatedDetail>('cart:updated', { detail }).
An even more thorough approach is to extend the global DocumentEventMap or HTMLElementEventMap interface via declaration merging with your own event name. After that, addEventListener('cart:updated', handler) automatically knows the correct CustomEvent<CartUpdatedDetail> type, with no type annotation needed on the handler itself, and a typo in the event name becomes an immediate compile error instead of a silent bug that only shows up at runtime.
// Strongly typed detail payload for a custom event
interface CartUpdatedDetail {
productId: string;
quantity: number;
totalItems: number;
}
// Extend the global map so addEventListener/dispatchEvent know the payload type
declare global {
interface DocumentEventMap {
'cart:updated': CustomEvent<CartUpdatedDetail>;
}
}
function dispatchCartUpdated(detail: CartUpdatedDetail): void {
document.dispatchEvent(new CustomEvent('cart:updated', { detail, bubbles: true }));
}
document.addEventListener('cart:updated', (event) => {
// event is inferred as CustomEvent<CartUpdatedDetail>, detail is fully typed
updateCartBadge(event.detail.totalItems);
});
function updateCartBadge(count: number): void {}
5. Typed event emitters for component communication
In component-based frontends that don't communicate through the DOM but through their own pub-sub system, a generic TypedEmitter is worth building instead of relying on a loose collection of callback functions. A mapped type such as { [K in keyof Events]?: Array<(payload: Events[K]) => void> } ensures that every event name from a defined Events interface enforces exactly the matching payload type, both when registering with on and when firing with emit.
The advantage over a generic EventTarget-based solution using CustomEvent becomes especially clear in larger component trees: the complete list of all possible events and their payloads lives in one central place, the Events interface, and every call to emit or on with a non-existent event name is immediately rejected by the compiler. For Magento frontends with several independent Hyvae components that need to communicate cart updates or filter changes, this cuts debugging time considerably.
// Typed pub-sub emitter for component-to-component communication
type EventMap = Record<string, unknown>;
class TypedEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
public on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
(this.listeners[event] ??= []).push(handler);
}
public off<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
this.listeners[event] = this.listeners[event]?.filter((h) => h !== handler);
}
public emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((handler) => handler(payload));
}
}
interface StoreEvents {
'product:selected': { productId: string };
'filter:changed': { facet: string; value: string };
}
const bus = new TypedEmitter<StoreEvents>();
bus.on('product:selected', ({ productId }) => {
// payload is fully typed, no "any" and no manual casting
console.log(`Selected ${productId}`);
});
bus.emit('product:selected', { productId: 'MS-1234' });
6. Typing events in Alpine.js components
Alpine.js is deliberately untyped by design, magic properties like $event, $el, or $dispatch only exist at runtime and are invisible to TypeScript without additional type declarations. Inside an x-on:click attribute in HTML, $event naturally can't be type-checked, but as soon as the actual handler logic is moved into a component function registered via Alpine.data(), the same typing rules apply as for any other TypeScript code: the parameter should be explicitly declared as MouseEvent, KeyboardEvent, or CustomEvent<T>, instead of implicitly falling back to any.
For communication between Alpine components, say between a Hyvae mini cart and a product card grid, $dispatch is really just a thin wrapper around CustomEvent and dispatchEvent. If the event name is registered together with its payload type beforehand in the global WindowEventMap or HTMLElementEventMap via declaration merging, the listener on x-on:cart-updated.window also benefits from full type checking, as long as its handler function comes from a typed TypeScript file rather than living as an inline expression in the markup.
7. Footguns: any, incorrect this, removeEventListener mismatches
The most common footgun is an implicitly or explicitly any-typed event parameter, usually the result of copying JavaScript code without strict mode or of generic callback signatures in third-party libraries. Once any enters the picture, all autocomplete and error checking disappears for the rest of the function, not just for the event parameter itself, which means typos in property names only surface at runtime.
A second, more subtle footgun involves this binding: when a class method is passed directly as an event handler, for example element.addEventListener('click', this.handleClick), the method loses its this context because addEventListener invokes the function without a bound object. TypeScript doesn't flag this as an error as long as this isn't type-checked inside the method body; the access to an instance property only fails at runtime. Arrow function class fields or an explicit .bind(this) call fix the problem reliably.
The third classic mistake concerns removeEventListener: the signature must match exactly the same function reference that was passed to addEventListener. A newly created arrow function or a function object freshly produced by .bind() is never referentially identical to a previous call, even if the code looks identical, so the listener is never actually removed. TypeScript doesn't check this referential equality, which means the mistake can only be avoided by carefully storing the original function reference.
8. Delegated events with type narrowing
Event delegation, where a single listener on a container element catches all clicks within its children, only stays cleanly typed in TypeScript if event.target is consistently narrowed instead of cast directly. The container itself provides a known, fixed type via event.currentTarget, but the element actually clicked can be any descendant element or even a text node, which is why target instanceof HTMLElement is an indispensable first narrowing step.
Building on that, target.closest<HTMLElement>('[data-action]') returns the nearest matching element, with the generic type parameter of closest directly determining the return type instead of requiring another cast afterward. A reusable delegation helper with the signature <T extends HTMLElement>(container: HTMLElement, selector: string, handler: (element: T, event: MouseEvent) => void) encapsulates this pattern once and provides the same type-safe access everywhere in the project, without every call site having to reimplement the narrowing logic itself.
9. Untyped vs. typed compared side by side
The following patterns show up in nearly every TypeScript codebase that processes DOM events. The table below puts the untyped, error-prone approach directly next to its type-safe counterpart.
| Pattern | Untyped (bad) | Typed (good) |
|---|---|---|
| Event parameter | (event: any) => void | (event: MouseEvent) => void |
| Event name | Dynamic string variable without a literal type | Generic wrapper with K extends keyof HTMLElementEventMap |
| target access | event.target as HTMLElement without a check | event.target instanceof HTMLElement narrowing |
| CustomEvent payload | new CustomEvent('x', { detail }) without a type parameter | CustomEvent<T> with declaration merging |
| removeEventListener | Anonymous arrow function at add and remove | Stored, named function reference |
| this binding | this.handleClick passed directly as the handler | Arrow function class field or .bind(this) |
In practice, these patterns tend to reinforce each other: a handler typed as any often also hides a broken this binding, because the compiler can no longer issue any warning at all. Consistently applying specific event subtypes, declaration merging for custom events, and named function references eliminates the vast majority of event-related runtime errors already at compile time.
Mironsoft
TypeScript architecture, type-safe frontends, and Hyvae integration for Magento stores
Ready to make your event handling type-safe?
We bring type-safe event architecture to your TypeScript codebase, from generic addEventListener wrappers to typed event emitters for communication between Hyvae components.
TypeScript audit
Reviewing existing event handlers for any types and footguns
Hyvae integration
Cleanly typing Alpine.js components and custom events
Emitter architecture
Building typed pub-sub systems for component communication
10. Summary
Event handling in TypeScript addresses one core problem: the generic Event type and any-typed callbacks destroy exactly the type safety TypeScript is supposed to deliver. Specific subtypes like MouseEvent, KeyboardEvent, and PointerEvent, combined with HTMLElementEventMap-based overload resolution, provide automatic type inference without manual casts. Narrowing instead of blind casting for target and currentTarget, CustomEvent<T> with an explicit payload type, and declaration merging for custom event names close the biggest gaps around self-defined events.
The decisive difference between fragile and robust event code rarely comes down to one single big change, but to consistent application across the entire codebase: named function references for removeEventListener, arrow function class fields against lost this binding, and a typed event emitter for components that don't communicate through the DOM. Combined with Alpine.js, this yields strictly typed TypeScript components that fit seamlessly into Hyvae themes.
Event Handling in TypeScript, the Essentials at a Glance
Specific event types
Use MouseEvent, KeyboardEvent, PointerEvent instead of the generic Event.
Narrowing instead of casting
instanceof checks for target/currentTarget instead of blind as casts.
CustomEvent<T>
Specify payload types explicitly and register them globally via declaration merging.
Clean listener management
Named function references for removeEventListener, arrow functions/bind for this.