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

  1. When you call target.addEventListener(name, listener), TypeScript first resolves name‘s literal type against the target’s event map (HTMLElementEventMap, DocumentEventMap, WindowEventMap, etc.) to pick the matching overload.
  2. 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.
  3. 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.
  4. 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.target before using element-specific properties — either with an as assertion when you’re certain of the runtime type, or with an instanceof check when you want the compiler to verify it for you.
  • Type custom event payloads with CustomEvent<T> instead of leaving the default any — it turns typos in event.detail.someProp into 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 EventListener at the call site rather than widening the listener’s own parameter type to Event, 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: narrow event.target to HTMLSelectElement).
  • Define a CustomEvent detail interface called CartUpdateDetail with an itemCount: number field, 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 KeyboardEvent is the only annotation that would satisfy HTMLElementEventMap["keydown"].

Summary

  • addEventListener is 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.target and event.currentTarget are always typed as EventTarget | null and must be narrowed (via as or instanceof) before use.
  • CustomEvent<T> lets you give your own application events a fully typed detail payload instead of any.
  • All of this checking happens purely at compile time — the emitted JavaScript has no type information at all.