Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~20 min· topic 2 of 6

Topic 13.2

Typing Events

In one line

When you call addEventListener with a known event name, TypeScript looks the name up in an event map and gives your handler the exact event type, so click gives mouse details and keydown gives the key. Custom events can be typed with CustomEvent<T>.

Think of it like this

A doorbell. When someone presses it, you don't get "something happened", you get a visitor with details: who, when, how long they pressed. Different bells deliver different details: a fire alarm tells you which floor. Events work like that. Each kind of event (a click, a key press, a form being sent) arrives as an object with different fields, and TypeScript knows which fields go with which kind.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Event
An object the browser makes when something happens, such as a click or key press.
Listener / handler
A function you attach to an element to be called when an event happens.
Event map
A TypeScript table from event names ("click") to event types (PointerEvent).
Bubbling
An event first fires on the element where it happened, then on each parent up the tree.
target
The element where the event really happened, for example the exact thing clicked.
currentTarget
The element your listener is attached to.
preventDefault
Stops the browser's normal reaction, such as a form reloading the page.
CustomEvent
An event you create yourself. Its extra data goes in detail.
Declaration merging
Adding to an existing interface from another place, so the original gains your entries.

Step by step

01The handler's parameter comes from the event name

You write "click" and TypeScript looks it up. You never write the type of e.

Write "keydown" instead and e becomes a KeyboardEvent: e.key now exists and e.clientX doesn't.

index.tswhole filets
button.addEventListener("click", (e) => {
  console.log(e.clientX); // fine: e is a mouse event
});
input.addEventListener("keydown", (e) => {
  console.log(e.key);     // fine: e is a keyboard event
});

02Where the table lives

Three tables cover the common cases: HTMLElementEventMap (elements), DocumentEventMap (document) and WindowEventMap (window). Open lib.dom.d.ts and search for them to see every event the browser has.

Where the table livesdiagram
Rendering diagram…

03target and currentTarget

Click the <b> inside a list. The listener is on the <ul>. currentTarget is the <ul>, target is the <b>. Both are EventTarget | null, so narrow before you use element features.

index.tswhole filets
list.addEventListener("click", (e) => {
  const t = e.target;           // EventTarget | null
  if (t instanceof HTMLElement) {
    console.log(t.closest("li")?.textContent);
  }
});

04Forms and SubmitEvent

A submit listener gets a SubmitEvent. Call e.preventDefault() first, or the browser sends the form and reloads the page. e.submitter tells you which button was used.

new FormData(form).get("email") returns FormDataEntryValue | null, which is File | string | null. A text field gives a string, but the type allows files, so check with typeof.

05Your own events

Create with new CustomEvent<T>(name, { detail }), send with element.dispatchEvent(event). To make listeners typed, merge the name into the event map.

index.tswhole filets
type CartDetail = { id: number; qty: number };

declare global {
  interface HTMLElementEventMap {
    "cart:add": CustomEvent<CartDetail>;
  }
}

Try it yourself

  1. 1

    Swap the event name

    In the first example, change "keydown" to "mousedown". Predict what happens to e.key before you look, then check the Types panel. Which type did e become?

  2. 2

    Typo in the event name

    Change "click" to "clik" in the first example. There is no error about the name: the handler's e is now a plain Event and the errors appear on e.button and e.clientX instead. The wrong name is silent; only the fallout is loud.

  3. 3

    Use the wrong detail

    In the last example, change e.detail.qty to e.detail.quantity. Read the error. Then change the detail object passed to new CustomEvent to { id: 7 } and see which line complains.

Code & diagrams

The event name decides the event type

The code fires the events itself so the Console is deterministic. In a real page they come from the user. The Types panel shows the handler parameters TypeScript worked out.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

click 0 0
Enter true
target vs currentTarget

The click is on the <b>, but the listener is on the <ul>. Both are the very general EventTarget | null, so the code narrows target before using tagName.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

true
B Tea
Forms: SubmitEvent and FormData

preventDefault stops the page from reloading. FormData.get gives File | string | null, so typeof narrows it to the string.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

sent with Send
ASHA@EXAMPLE.COM
A typed custom event

Remove the declare global block and look at the Types panel: e becomes a plain Event with no detail. Declaration merging is what teaches the event map about your event.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

added 7 x 2

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read a key from a click

In the first example, in the click handler change e.clientX to e.key.

terminal
$ Types panel
── what you'll see ──
index.ts(6,35): error TS2339: Property 'key' does not exist on type 'PointerEvent'.

Myth vs fact

Myth

e.target is the element I attached the listener to.

Fact

That is currentTarget. target is whatever was actually hit, possibly a child, and it is typed EventTarget | null.

Myth

A wrong event name is a type error.

Fact

No. addEventListener also accepts any string, so an unknown name compiles and you get a plain Event in the handler. Only the fallout (such as e.clientX not existing on Event) is an error.

Myth

CustomEvent<T> checks that the detail has type T for every listener.

Fact

It checks the new CustomEvent call. For listeners you must teach the event map about the name, or cast.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The overloads are addEventListener<K extends keyof HTMLElementEventMap>(...) first and addEventListener(type: string, listener: EventListenerOrEventListenerObject, ...) second. TypeScript tries them in order; a typo falls through to the second. A stricter helper on<K extends keyof HTMLElementEventMap> that only has the first overload turns a bad event name into a compile error.

  • ▸

    In recent lib.dom.d.ts the click entry is PointerEvent, not MouseEvent (a PointerEvent extends MouseEvent, so all mouse fields exist). Check the lib file for your TypeScript version if an event type surprises you.

  • ▸

    this inside the listener is typed as the element when you pass a function expression. In an arrow function this is the outer one. Prefer e.currentTarget, narrowed, or the variable you already have.

Remember this

  1. 1

    The problem this solves: every handler receives an event object, and without types you would have to remember by heart which fields a click has and which a key press has. TypeScript picks the right event type from the event name, so a wrong field is an error instead of a silent undefined. An event is an object the browser creates when something happens (a click, a key press, the page loading) and passes to every function that asked to hear about it. That function is the handler or listener. You attach one with element.addEventListener("click", handler).

  2. 2

    How does TypeScript know that a "click" handler gets a mouse event? addEventListener has an overload (a second written form) that takes the event name as a type: addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (ev: HTMLElementEventMap[K]) => any). HTMLElementEventMap is a table from names to event types: "click" → PointerEvent, "keydown" → KeyboardEvent, "input" → InputEvent, and so on. Because K is read from the string you pass, the handler's parameter is typed for you: you don't annotate e.

  3. 3

    This only works when the name is known and the handler is written inline. If you write the handler separately you must type its parameter yourself (function onKey(e: KeyboardEvent) {}), and if you use a name that is not in the table (a typo like "clik", or a custom event name), TypeScript falls back to the general overload where the handler receives a plain Event, with no error. Mistyped event names are silent: that is a real source of bugs.

  4. 4

    Each event type adds its own fields. MouseEvent has clientX, clientY, button, shiftKey. KeyboardEvent has key ("Enter", "a"), code (the physical key) and repeat. SubmitEvent (for <form>) has submitter, the button that was pressed. InputEvent has data. FocusEvent has relatedTarget. All of them also have the fields of the base Event: type, preventDefault(), stopPropagation(), target, currentTarget.

  5. 5

    **target vs currentTarget. Events bubble**: a click on a <b> inside a <li> inside a <ul> also fires for the <li> and the <ul>. e.target is the thing that was actually clicked (the <b>). e.currentTarget is the element your listener is attached to (the <ul>). Both are typed as **EventTarget | null**, the most general type, because a target can be a window, a document or a request, not only an element. To use them as an element, narrow with instanceof HTMLElement. In a listener attached with addEventListener, the this value is typed as the element, which is a safe way to get the exact type.

  6. 6

    Custom events let your own code announce things: new CustomEvent("cart:add", { detail: { id: 7, qty: 2 } }). The extra data lives in detail. CustomEvent<T> carries the type of detail. A listener for an unknown name receives a plain Event, which has no detail, so you'd need a cast. The clean way is to add your event to the map with declaration merging (Topic 16.3): declare global { interface HTMLElementEventMap { "cart:add": CustomEvent<CartDetail> } }. From then on addEventListener("cart:add", e => e.detail.id) is fully typed, and a wrong detail in the listener is caught.

Explain it without notes

01

How does TypeScript know the type of e in button.addEventListener("click", (e) => ...)?

02

What is the difference between target and currentTarget, and why must you narrow them?

03

How do you type a custom event so that listeners see its detail?

04

Why can a misspelled event name compile without an error?

Practice

01

Write function onEnter(input: HTMLInputElement, run: (text: string) => void): void that calls run(input.value) when the user presses Enter in the input.

02

Add a typed "theme:change" event with detail { dark: boolean } for document, and a listener that logs the detail.

Trade-offs

  • ↔

    Declaration merging gives typed custom events across the whole project, but it is global: two libraries using the same event name collide. Prefix event names ("cart:add").

  • ↔

    A cast like e as CustomEvent<CartDetail> is local and quick, but unchecked. Use it only in one-off code.

Done when you can

  • I can explain how the event name selects the handler's event type.

  • I can name the fields of MouseEvent, KeyboardEvent and SubmitEvent I use most.

  • I can explain target vs currentTarget and narrow them.

  • I can create and listen to a typed CustomEvent<T>.

  • I know a misspelled event name is silent and how to avoid it.