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

💡 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: scroll

3. 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: blur

Common Errors (and the fix)

Pro Tips

📚 Keep Learning

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

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

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.

Related lessons