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.
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.
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.
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.
type CartDetail = { id: number; qty: number };
declare global {
interface HTMLElementEventMap {
"cart:add": CustomEvent<CartDetail>;
}
}Try it yourself
- 1
Swap the event name
In the first example, change
"keydown"to"mousedown". Predict what happens toe.keybefore you look, then check the Types panel. Which type didebecome? - 2
Typo in the event name
Change
"click"to"clik"in the first example. There is no error about the name: the handler'seis now a plainEventand the errors appear one.buttonande.clientXinstead. The wrong name is silent; only the fallout is loud. - 3
Use the wrong detail
In the last example, change
e.detail.qtytoe.detail.quantity. Read the error. Then change thedetailobject passed tonew CustomEventto{ id: 7 }and see which line complains.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
click 0 0
Enter trueThe 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
Console
Nothing logged yet.Expected console output
true
B TeapreventDefault stops the page from reloading. FormData.get gives File | string | null, so typeof narrows it to the string.
Preview
Console
Nothing logged yet.Expected console output
sent with Send
ASHA@EXAMPLE.COMRemove 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
Console
Nothing logged yet.Expected console output
added 7 x 2Break 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.
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 andaddEventListener(type: string, listener: EventListenerOrEventListenerObject, ...)second. TypeScript tries them in order; a typo falls through to the second. A stricter helperon<K extends keyof HTMLElementEventMap>that only has the first overload turns a bad event name into a compile error. - ▸
In recent
lib.dom.d.tstheclickentry isPointerEvent, notMouseEvent(aPointerEventextendsMouseEvent, so all mouse fields exist). Check the lib file for your TypeScript version if an event type surprises you. - ▸
thisinside the listener is typed as the element when you pass a function expression. In an arrow functionthisis the outer one. Prefere.currentTarget, narrowed, or the variable you already have.
Remember this
- 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 withelement.addEventListener("click", handler). - 2
How does TypeScript know that a
"click"handler gets a mouse event?addEventListenerhas 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).HTMLElementEventMapis a table from names to event types:"click"→PointerEvent,"keydown"→KeyboardEvent,"input"→InputEvent, and so on. BecauseKis read from the string you pass, the handler's parameter is typed for you: you don't annotatee. - 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 plainEvent, with no error. Mistyped event names are silent: that is a real source of bugs. - 4
Each event type adds its own fields.
MouseEventhasclientX,clientY,button,shiftKey.KeyboardEventhaskey("Enter", "a"),code(the physical key) andrepeat.SubmitEvent(for<form>) hassubmitter, the button that was pressed.InputEventhasdata.FocusEventhasrelatedTarget. All of them also have the fields of the baseEvent:type,preventDefault(),stopPropagation(),target,currentTarget. - 5
**
targetvscurrentTarget. Events bubble**: a click on a<b>inside a<li>inside a<ul>also fires for the<li>and the<ul>.e.targetis the thing that was actually clicked (the<b>).e.currentTargetis 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 withinstanceof HTMLElement. In a listener attached withaddEventListener, thethisvalue is typed as the element, which is a safe way to get the exact type. - 6
Custom events let your own code announce things:
new CustomEvent("cart:add", { detail: { id: 7, qty: 2 } }). The extra data lives indetail.CustomEvent<T>carries the type ofdetail. A listener for an unknown name receives a plainEvent, which has nodetail, 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 onaddEventListener("cart:add", e => e.detail.id)is fully typed, and a wrongdetailin the listener is caught.
Explain it without notes
How does TypeScript know the type of e in button.addEventListener("click", (e) => ...)?
What is the difference between target and currentTarget, and why must you narrow them?
How do you type a custom event so that listeners see its detail?
Why can a misspelled event name compile without an error?
Practice
Write function onEnter(input: HTMLInputElement, run: (text: string) => void): void that calls run(input.value) when the user presses Enter in the input.
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,KeyboardEventandSubmitEventI use most.I can explain
targetvscurrentTargetand 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.