DOM & Event Types
Reviewed & published by Brayan K
TypeScript's DOM types give every element and event a precise shape — from HTMLInputElement to MouseEvent — so querySelector, addEventListener, and event handlers are fully checked and autocompleted.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Steer querySelector's result type with a generic
- Handle the | null result of element lookups
- Use specific element types like HTMLInputElement
- Tell Event apart from MouseEvent and KeyboardEvent
- Get correctly-typed handlers from addEventListener
- Narrow e.target (EventTarget) before using it
💡 Real-World Analogy
Think of the DOM as a building full of labelled rooms. querySelector is asking the receptionist for a room by name — they might hand you a key (the element) or shrug because it doesn't exist (null). Telling them "I want the input room" (the generic) means the key fits an input's locks specifically. Events are messages slipped under the door: a "click" note carries coordinates, a "keydown" note carries a key — same envelope (Event), different contents.
1. Element Types & querySelector Generics
Every DOM element has a precise type — HTMLInputElement, HTMLButtonElement, HTMLCanvasElement, all extending HTMLElement. querySelector takes a generic so you can say which one you expect, and it always returns YourType | null because the element may not be found.
// The DOM lib types every element. Two ways to get the right type:
// 1) The generic overload on querySelector picks the element type:
const input = document.querySelector<HTMLInputElement>("#email");
// input: HTMLInputElement | null -> .value is available after a null check
if (input) console.log(input.value);
// 2) getElementById returns HTMLElement | null (no generic):
const box = document.getElementById("box"); // HTMLElement | null
// Built-in element types you'll meet constantly:
// HTMLElement, HTMLInputElement, HTMLButtonElement,
// HTMLAnchorElement, HTMLFormElement, HTMLCanvasElement
const canvas = document.querySelector<HTMLCanvasElement>("canvas");
const ctx = canvas?.getContext("2d"); // typed via HTMLCanvasElement// In a browser, querySelector returns a typed Element. In .ts you steer the type
// with a generic:
// const input = document.querySelector<HTMLInputElement>("#email");
// input.value // ✅ typed, because the generic said HTMLInputElement
// There is no DOM in node, so here is a typed-handler MAP that models the same idea:
// a registry where each selector maps to an element-like object with a known shape.
const elements = {
"#email": { tag: "input", value: "ada@site.com" },
"#title": { tag: "h1", textContent: "Welcome" },
"#submit": { tag: "button", disabled: false },
};
function querySelector(sel) {
return elements[sel] || null; // null when not found — exactly like the DOM
}
const email = querySelector("#email");
console.log(email.tag); // input
console.log(email.value); // ada@site.com
const missing = querySelector("#nope");
console.log(missing); // null (must be checked before use!)2. Event vs MouseEvent vs KeyboardEvent
Events form a hierarchy: MouseEvent and KeyboardEvent extend the base Event, adding members like clientX or key. When you call addEventListener("click", ...), TypeScript already knows the handler gets a MouseEvent — no casting needed.
// addEventListener knows which Event subtype each event name produces:
button.addEventListener("click", (e) => {
// e: MouseEvent -> e.clientX / e.clientY are available
console.log(e.clientX, e.clientY);
});
input.addEventListener("keydown", (e) => {
// e: KeyboardEvent -> e.key is available
if (e.key === "Enter") submit();
});
// The hierarchy (simplified):
// Event
// ├─ UIEvent
// │ ├─ MouseEvent (clientX, clientY, button)
// │ └─ KeyboardEvent (key, code, altKey)
// └─ ... (FocusEvent, InputEvent, ...)// Event types form a hierarchy: MouseEvent and KeyboardEvent EXTEND Event.
// We model that with plain objects carrying a 'type' discriminant, then narrow.
function handle(event) {
switch (event.type) {
case "click":
// a MouseEvent has x/y coordinates
return "click at (" + event.x + ", " + event.y + ")";
case "keydown":
// a KeyboardEvent has a 'key'
return "key: " + event.key;
default:
return "event: " + event.type;
}
}
console.log(handle({ type: "click", x: 10, y: 20 })); // click at (10, 20)
console.log(handle({ type: "keydown", key: "Enter" })); // key: Enter
console.log(handle({ type: "scroll" })); // event: scroll3. Narrowing e.target & Typed Handlers
e.target is deliberately broad — EventTarget | null — because the event could have started on any descendant. To read element properties you narrow it with instanceof (or reach for the more specific e.currentTarget). The handler-registration pattern itself is captured by a tiny typed event-emitter.
// e.target is EventTarget | null — NARROW before using element-only members:
form.addEventListener("submit", (e) => {
e.preventDefault();
const target = e.target; // EventTarget | null
if (target instanceof HTMLFormElement) { // narrow!
const data = new FormData(target); // ✅ now typed as a form
}
});
// Prefer e.currentTarget when you need the element the listener is ON —
// you can type the whole handler so currentTarget is known:
const onClick = (e: MouseEvent & { currentTarget: HTMLButtonElement }) => {
e.currentTarget.disabled = true; // ✅ typed without an instanceof check
};// e.target is typed as EventTarget | null — broad on purpose. To read element
// properties you must NARROW it (instanceof) first. We model that handler wiring
// with a tiny typed EVENT-EMITTER — the same pattern addEventListener uses.
function createEmitter() {
const handlers = {}; // event name -> array of listeners
return {
on(name, fn) {
(handlers[name] = handlers[name] || []).push(fn);
},
emit(name, payload) {
(handlers[name] || []).forEach((fn) => fn(payload));
},
};
}
const bus = createEmitter();
bus.on("login", (user) => console.log("Welcome, " + user.name));
bus.on("logout", () => console.log("Bye!"));
bus.emit("login", { name: "Ada" }); // Welcome, Ada
bus.emit("logout"); // Bye!🎯 Your Turn
Narrow an event by its type field, just like you'd narrow a DOM event union. Fill in the two blanks marked ___, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// In .ts the event would be a union narrowed by 'type'. Narrow it here too.
function describe(event) {
if (event.type === ___) { // 👉 the string "mouseover"
return "hovering " + event.target;
}
return "other: " + event.___; // 👉 read the 'type' field
}
console.log(describe({ type: "mouseover", target: "#btn" })); // hovering #btn
console.log(describe({ type: "blur" })); // other: blur
// ✅ Expected output:
// hovering #btn
// other: blurCommon Errors (and the fix)
- ❌ "Object is possibly 'null'" — you used a querySelector result without checking. ✅ Guard with if (el) or use optional chaining el?.value.
- ❌ "Property 'value' does not exist on type 'Element'" — you didn't specify the element type. ✅ Use querySelector<HTMLInputElement> so .value is known.
- ❌ "Property 'clientX' does not exist on type 'Event'" — your handler is typed as plain Event. ✅ Use the correct event name so TS infers MouseEvent, or narrow.
- ❌ "Property 'value' does not exist on type 'EventTarget'" — e.target is too broad. ✅ if (e.target instanceof HTMLInputElement) before reading it.
Pro Tips
- 💡 Enable the "dom" lib in tsconfig.json (compilerOptions.lib) so all element/event types are available.
- 💡 Prefer instanceof over as for e.target — it's a real runtime check, not just a type assertion.
- 💡 Reach for e.currentTarget when you want the element the listener is attached to; it's easier to type than e.target.
- 💡 Use the HTMLElementTagNameMap behind the scenes: querySelector("input") (a tag selector) already infers HTMLInputElement automatically.
📚 Keep Learning
- 📖 TypeScript Handbook — DOM Manipulation
- 📖 Handbook — instanceof Narrowing
- 📖 tsconfig — the "dom" lib option
Mini-Challenge: A Typed Handler Map
No blanks this time — just a brief and a starting outline. Build the dispatcher yourself, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: a typed handler MAP (the heart of an event system).
// 1. Make a 'handlers' object: click(e) -> "clicked " + e.id,
// submit(e) -> "submitted form " + e.formId.
// 2. Write dispatch(name, event): look up handlers[name]; call it if present,
// otherwise return "no handler".
// 3. Log dispatch("click", { id: "save" }), dispatch("submit", { formId: "login" }),
// and dispatch("hover", {}).
//
// ✅ Expected output:
// clicked save
// submitted form login
// no handler
// your code here🎉 Lesson Complete
- ✅ querySelector<HTMLInputElement> steers the result to a specific element type
- ✅ Element lookups return YourType | null — always handle the null
- ✅ MouseEvent and KeyboardEvent extend Event with extra members
- ✅ addEventListener infers the right event subtype from the event name
- ✅ Narrow e.target (EventTarget) with instanceof before using element members
Practice quiz
How do you tell TypeScript the exact element type from querySelector?
- Cast with 'as any'
- Use getElementById
- Use the generic: document.querySelector<HTMLInputElement>("#email")
- It is automatic for every selector
Answer: Use the generic: document.querySelector<HTMLInputElement>("#email"). The generic overload steers the result type, e.g. querySelector<HTMLInputElement>.
What is the result type of 'document.querySelector<HTMLInputElement>("#email")'?
- HTMLInputElement | null
- HTMLInputElement
- Element
- HTMLElement
Answer: HTMLInputElement | null. querySelector returns YourType | null because the element might not be found.
Why is querySelector's result possibly null?
- A bug in the DOM lib
- Because of strict mode only
- It is never null
- The selector might not match any element
Answer: The selector might not match any element. TypeScript types it as YourType | null to force you to handle the not-found case.
What is the return type of document.getElementById("box")?
- HTMLDivElement
- HTMLElement | null
- Element
- any
Answer: HTMLElement | null. getElementById has no generic and returns HTMLElement | null.
Which relationship is correct for event types?
- MouseEvent and KeyboardEvent extend Event
- Event extends MouseEvent
- They are unrelated
- KeyboardEvent extends MouseEvent
Answer: MouseEvent and KeyboardEvent extend Event. MouseEvent and KeyboardEvent both extend the base Event with extra members.
Inside addEventListener("click", handler), what type is the handler's event?
- Event
- KeyboardEvent
- MouseEvent
- any
Answer: MouseEvent. TypeScript infers the correct subtype (MouseEvent) from the known event name 'click'.
Which property does a KeyboardEvent have that the base Event does not?
- clientX
- key
- preventDefault
- target
Answer: key. KeyboardEvent adds members like 'key' and 'code'; clientX belongs to MouseEvent.
What is the type of e.target?
- HTMLElement
- Element
- the specific element always
- EventTarget | null
Answer: EventTarget | null. e.target is EventTarget | null, deliberately broad, so you must narrow it before use.
How do you safely read element-only members off e.target?
- Cast with 'as HTMLInputElement'
- Narrow with 'if (e.target instanceof HTMLInputElement)'
- Ignore the type
- Use e.target.value directly
Answer: Narrow with 'if (e.target instanceof HTMLInputElement)'. instanceof both checks and narrows, far safer than an 'as' cast.
Why does e.currentTarget often not need narrowing while e.target does?
- currentTarget is always null
- target is faster
- currentTarget is the element the listener is attached to, so it can be typed directly
- They are identical
Answer: currentTarget is the element the listener is attached to, so it can be typed directly. currentTarget is the element the listener is on and can be typed; target could be any descendant.
Continue this course
- Previous: Typing Promises & async/await
- Next: Generic Defaults & Inference — Default type parameters, how inference works, and when to annotate
- Quick reference: TypeScript cheat sheet
Frequently asked questions
How do I tell TypeScript the exact element type from querySelector?
Use the generic overload: document.querySelector<HTMLInputElement>('#email'). The result is HTMLInputElement | null, so after a null check you get input.value and other input-specific members. Without the generic you'd get the base Element, which lacks those properties.
Why is querySelector's result possibly null?
Because the selector might not match anything. TypeScript types the result as Element | null (or YourType | null) to force you to handle the 'not found' case. Check for null — or use the non-null assertion (!) only when you're certain the element exists.
What's the difference between Event, MouseEvent, and KeyboardEvent?
Event is the base type; MouseEvent and KeyboardEvent extend it with extra properties (clientX/clientY for mouse, key/code for keyboard). When you call addEventListener with a known event name like 'click' or 'keydown', TypeScript automatically gives your handler the correct subtype, so the extra properties are available.
Why does e.target need narrowing but e.currentTarget often doesn't?
e.target is typed as EventTarget | null because the event could have originated on any descendant element, so TypeScript can't know its specific type — you narrow with instanceof. e.currentTarget is the element the listener is attached to, which you can type directly (or via a typed handler), so it's often known without a check.