TypeScript Typing Events
Whenever you attach a listener to a button click, a key press, or your own application-level event, TypeScript needs to know exactly what shape of object that listener will receive. Get this wrong and you either lose autocomplete on useful properties like clientX or key, or you write code that crashes at runtime because you assumed a property existed that TypeScript quietly let you access. This lesson covers how TypeScript types built-in DOM events, how to safely narrow event.target, and how to build your own strongly-typed custom events with CustomEvent<T>.
Overview: How Event Typing Works
The DOM type declarations that ship with TypeScript (in lib.dom.d.ts) define a large family of interfaces that describe every kind of event: MouseEvent, KeyboardEvent, FocusEvent, InputEvent, and dozens more, all ultimately extending the base Event interface. Each of these adds properties relevant to that event kind — MouseEvent adds clientX/clientY/button, KeyboardEvent adds key/code/altKey, and so on.
The clever part is how addEventListener knows which interface to hand you. It isn’t magic — it’s a generic overload keyed by a lookup table. Every element type declares an event map, like HTMLElementEventMap, which is a big object type mapping event-name strings to their corresponding event interfaces: { click: MouseEvent; keydown: KeyboardEvent; focus: FocusEvent; ... }. The signature of addEventListener looks roughly like this:
addEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): void;
When you call el.addEventListener("click", ...), TypeScript infers K as the literal type "click" from the string you passed in, then looks up HTMLElementEventMap["click"], which resolves to MouseEvent. That’s why the callback parameter is automatically typed as MouseEvent with no annotation needed — this is contextual typing, one of TypeScript’s most useful inference features. This also explains why annotating the event parameter yourself can backfire, which we’ll see in Common Mistakes.
It’s also worth remembering that all of this is a compile-time-only concept. Once compiled to JavaScript, every type annotation is erased — event: MouseEvent becomes just event in the output. The runtime object is a plain MouseEvent instance regardless of whether TypeScript typed it correctly; TypeScript’s job is purely to catch mismatches between what you assume the object looks like and what the DOM API map says it should be, before you ever run the code.
Syntax
target.addEventListener(eventName, (event) => {
// event's type is inferred from eventName via the element's event map
}, options);
| Part | Meaning |
|---|---|
eventName |
A string literal like "click" or "keydown"; used to look up the event type in the target’s event map. |
event |
Inferred automatically — do not annotate it manually unless you want to lose that inference. |
event.target |
Typed as EventTarget | null, not the specific element type — must be narrowed before accessing element-specific properties. |
event.currentTarget |
Also typed as EventTarget | null in the base Event interface, even though at runtime it’s the element the listener is attached to. |
options |
Optional boolean | AddEventListenerOptions, same as plain JavaScript. |
Examples
Example 1: Basic click handling with inference
const button = document.querySelector<HTMLButtonElement>("#submit-btn");
button?.addEventListener("click", (event) => {
console.log(`Clicked at (${event.clientX}, ${event.clientY})`);
console.log(`Button pressed: ${event.button}`);
});
Output:
(No output printed immediately — the callback only runs in a browser when the button is actually clicked. If clicked, it would log something like: "Clicked at (120, 48)" and "Button pressed: 0")
Notice the generic on querySelector<HTMLButtonElement>. Without it, querySelector returns the more generic Element | null, whose event map doesn’t include most UI events like click with a specific event type — you’d fall back to the loosely-typed overload and lose access to clientX/button. Supplying the element type up front lets TypeScript look up HTMLElementEventMap["click"] and correctly infer event: MouseEvent.
Example 2: Narrowing event.target and reading keyboard state
const emailInput = document.querySelector<HTMLInputElement>("#email");
emailInput?.addEventListener("input", (event) => {
const target = event.target as HTMLInputElement;
console.log(`Current value: ${target.value}`);
});
emailInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
console.log("Form submitted via Enter key");
}
});
Output:
(No output printed immediately — these run only when the user types into or presses Enter inside the email field in a browser.)
The "input" event is typed as a plain Event in the DOM lib, so event.target is EventTarget | null — it has no value property. Because we already know this listener is only ever attached to an HTMLInputElement, an as HTMLInputElement assertion is a reasonable, common way to narrow it. The "keydown" listener, by contrast, is correctly inferred as KeyboardEvent, so event.key is available with no cast needed.
Example 3: Strongly-typed custom events
interface UserLoginDetail {
username: string;
timestamp: number;
}
function dispatchUserLogin(target: EventTarget, detail: UserLoginDetail): void {
const event = new CustomEvent<UserLoginDetail>("user-login", { detail });
target.dispatchEvent(event);
}
function onUserLogin(event: CustomEvent<UserLoginDetail>): void {
console.log(`${event.detail.username} logged in at ${event.detail.timestamp}`);
}
document.addEventListener("user-login", onUserLogin as EventListener);
dispatchUserLogin(document, { username: "ada", timestamp: 1700000000000 });
Output:
ada logged in at 1700000000000
CustomEvent is generic over its detail payload: CustomEvent<UserLoginDetail> means event.detail is fully typed instead of any. The one wrinkle is that "user-login" isn’t a key of DocumentEventMap, so addEventListener falls back to its generic string overload, which expects a plain EventListener (a function taking Event). That’s why onUserLogin, typed to take CustomEvent<UserLoginDetail>, needs an as EventListener cast when passed in — a very common, safe pattern for custom events since you control both the dispatch and the listener and know the real runtime type. In a browser, dispatchEvent invokes matching listeners synchronously, so the log line prints immediately.
Under the Hood: What the Compiler Actually Checks
- When you call
target.addEventListener(name, listener), TypeScript first resolvesname‘s literal type against the target’s event map (HTMLElementEventMap,DocumentEventMap,WindowEventMap, etc.) to pick the matching overload. - It uses that overload to determine the expected type of
listener‘s parameter, then checks your callback against it using contextual typing — this is why an unannotated arrow function parameter gets the correct, specific event type for free. - If you supply your own annotation instead of letting inference run, TypeScript checks your annotation against the expected parameter type using normal function-parameter assignability rules (contravariant, under
strictFunctionTypes). If they don’t line up, you get an error — even though the string literal ("keydown") and your annotation (MouseEvent) individually look fine. - None of this exists once compiled. The emitted JavaScript is just
target.addEventListener(name, listener)with zero trace of any interface name — the browser doesn’t know or care what TypeScript inferred. All the safety happens strictly at compile time.
Common Mistakes
Mistake 1: Accessing event.target without narrowing
const input = document.querySelector<HTMLInputElement>("#age");
input?.addEventListener("change", (event) => {
console.log(event.target.value);
});
This fails to compile. tsc reports that event.target is possibly null, and separately that Property 'value' does not exist on type 'EventTarget' — the base Event interface deliberately types target as the generic EventTarget | null because, in principle, an event can be dispatched on any kind of target, not just an HTMLInputElement. The fix is to narrow it explicitly:
const input = document.querySelector<HTMLInputElement>("#age");
input?.addEventListener("change", (event) => {
const target = event.target as HTMLInputElement;
console.log(target.value);
});
Mistake 2: Manually annotating the event parameter with the wrong type
const box = document.querySelector<HTMLDivElement>("#box");
box?.addEventListener("keydown", (event: MouseEvent) => {
console.log(event.button);
});
This also fails to compile. Even though "keydown" is a perfectly valid event name, explicitly typing the callback parameter as MouseEvent conflicts with the type HTMLElementEventMap["keydown"] resolves to, which is KeyboardEvent. tsc reports that the provided listener function is not assignable to the expected parameter type, because KeyboardEvent and MouseEvent are unrelated sibling interfaces. The fix is simply to remove the incorrect annotation and let contextual typing do its job:
const box = document.querySelector<HTMLDivElement>("#box");
box?.addEventListener("keydown", (event) => {
console.log(event.key);
});
Best Practices
- Let TypeScript infer event parameter types from context; only add an explicit annotation when you have a very specific reason, and make sure it matches the event map.
- Use a generic type argument on
querySelector/getElementById(e.g.querySelector<HTMLInputElement>(...)) so downstream event listeners resolve to the correct, more specific event map. - Always narrow
event.targetbefore using element-specific properties — either with anasassertion when you’re certain of the runtime type, or with aninstanceofcheck when you want the compiler to verify it for you. - Type custom event payloads with
CustomEvent<T>instead of leaving the defaultany— it turns typos inevent.detail.somePropinto compile errors. - When attaching a listener for a custom event name that isn’t part of the built-in event maps, cast the listener to
EventListenerat the call site rather than widening the listener’s own parameter type toEvent, so the function body still gets full detail typing. - Remember types vanish at runtime — a correct type doesn’t guarantee the DOM actually behaves that way; it only guarantees your code is internally consistent with the declared API shape.
Practice Exercises
- Write a typed listener on a
<select>element for the"change"event that reads the newly selected value (hint: narrowevent.targettoHTMLSelectElement). - Define a
CustomEventdetail interface calledCartUpdateDetailwith anitemCount: numberfield, then write both a dispatch function and a listener function for a"cart-update"event, fully typed end to end. - Take the broken snippet from Mistake 2 above, and instead of removing the annotation, fix it by supplying the correct explicit type. Confirm mentally why
KeyboardEventis the only annotation that would satisfyHTMLElementEventMap["keydown"].
Summary
addEventListeneris generic and looks up the expected event interface via the element’s event map (HTMLElementEventMap,DocumentEventMap, etc.), driven by contextual typing.- Avoid manually annotating event callback parameters — let inference supply the correct, specific event interface, since a wrong explicit annotation causes a real compile error.
event.targetandevent.currentTargetare always typed asEventTarget | nulland must be narrowed (viaasorinstanceof) before use.CustomEvent<T>lets you give your own application events a fully typeddetailpayload instead ofany.- All of this checking happens purely at compile time — the emitted JavaScript has no type information at all.
