Skip to content

38 β€” The DOM and Events, Typed

Level: Advanced Β· Time: ~40 min Β· Prerequisites: Lesson 37 β€” Your First TypeScript Web App with Vite


Why this matters

The DOM is the largest API any beginner touches, and it is not type-safe by nature: every query may return nothing, every element may be any of forty-odd subclasses, and every event carries a different payload. TypeScript cannot make that API smaller, but it can make each uncertainty visible at the point where you ignore it, which is exactly the class of bug that reaches production in browser code. The techniques in this lesson are also the ones frameworks hide from you: when a component library misbehaves, this is the layer you fall back to.

Getting elements without lying about it

document.querySelector returns Element | null, and the generic you pass describes what you expect to find. That expectation is a claim, not a check β€” which is why a shared helper that fails loudly beats a ! on every line:

// src/dom.ts
export function getElement<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (element === null) {
    throw new Error(`Expected an element matching "${selector}"`);
  }
  return element;
}
const app = getElement<HTMLDivElement>("#app");
const kwhInput = getElement<HTMLInputElement>("#kwh");

Note the difference the generic makes: on a plain Element there is no .value, no .dataset and no .checked. HTMLInputElement has all three, HTMLAnchorElement has .href, and HTMLElement is the common parent with .textContent, .classList and .style. Choosing the right one is the whole typing job.

Collections behave differently β€” querySelectorAll<HTMLTableRowElement>("tbody tr") returns a NodeListOf<HTMLTableRowElement>, which is live-free, indexable, has .forEach, and is iterable when DOM.Iterable is in lib (it is, from Lesson 37). Unlike an array, it has no .map or .filter: convert first with Array.from(rows) when you need them, and remember that the result is a snapshot, not a live view.

Building elements instead of injecting strings

textContent sets text; innerHTML parses HTML. That distinction is a security boundary, not a style preference:

const row = document.createElement("tr");

const regionCell = document.createElement("td");
regionCell.textContent = day.region; // "<img src=x onerror=...>" stays literal text
row.append(regionCell);

// never this, with anything that came from a user, a file or an API:
// row.innerHTML = `<td>${day.region}</td>`;

When a table grows, appending element by element causes one layout pass per insertion. A DocumentFragment is an off-document container that makes the whole batch a single insertion:

function buildRows(rows: readonly ProductionDay[]): DocumentFragment {
  const fragment = document.createDocumentFragment();
  for (const day of rows) {
    const tr = document.createElement("tr");
    const date = document.createElement("td");
    date.textContent = day.date;
    const kwh = document.createElement("td");
    kwh.textContent = day.kwh.toFixed(1);
    tr.append(date, kwh);
    fragment.append(tr);
  }
  return fragment;
}

tbody.replaceChildren(buildRows(filtered));

Events, and the difference between target and currentTarget

addEventListener is generic over the event name, so the payload is inferred from the string you pass: "click" gives a MouseEvent, "keydown" a KeyboardEvent, "input" an InputEvent, "submit" a SubmitEvent. Type the handler by naming the event type, and the useful properties appear:

kwhInput.addEventListener("input", (event: InputEvent) => {
  console.log(event.data, event.inputType); // typed, not any
});

kwhInput.addEventListener("keydown", (event: KeyboardEvent) => {
  if (event.key === "Escape") kwhInput.value = "";
});

event.target is the element that actually received the event β€” possibly a child β€” and it is typed EventTarget | null, so it must be narrowed before use. event.currentTarget is the element the listener is attached to, and it carries no useful type either. The reliable pattern is to ignore both for typing purposes and use the reference you already hold:

table.addEventListener("click", (event) => {
  if (!(event.target instanceof HTMLButtonElement)) return;
  const month = event.target.dataset.month; // string | undefined
  if (month !== undefined) filterStore.set({ ...filterStore.get(), month }); // the store, below
});

Forms without trusting the browser

An HTMLFormElement gives you FormData, and FormData.get returns FormDataEntryValue | null, which is string | File | null. It never returns a number, because HTML form values are strings all the way down:

const form = getElement<HTMLFormElement>("#filter-form");

form.addEventListener("submit", (event: SubmitEvent) => {
  event.preventDefault(); // stop the full page reload
  const data = new FormData(form);
  const month = String(data.get("month") ?? "all");
  const kwh = Number(data.get("kwh")); // NaN when empty or nonsense
  if (!Number.isFinite(kwh)) {
    kwhInput.setCustomValidity("Enter a number of kWh");
    kwhInput.reportValidity();
    return;
  }
  kwhInput.setCustomValidity("");
  filterStore.set({ ...filterStore.get(), month });
});

Two HTML realities that surprise people: type="number" still gives .value as a string and still allows intermediate garbage such as 1e or -; and input.valueAsNumber is a real number but is NaN for an empty field. For declared rules β€” required, min, max, step β€” the browser already knows the answer, and input.checkValidity(), input.validity.rangeUnderflow and form.reportValidity() read it without a line of your own validation logic. Use those first, and setCustomValidity for the rules only you can know. Show the message next to the field rather than in an alert, and give it role="alert" so a screen reader announces it.

One store, one render β€” state in plain TypeScript

A filter is state, so give it a type, keep it in one object, and rebuild the view whenever it changes. Nothing here needs a library:

type Filter = {
  readonly month: string | "all";
  readonly region: Region | "all";
};

function applyFilter(rows: readonly ProductionDay[], filter: Filter): ProductionDay[] {
  return rows.filter(
    (row) =>
      (filter.month === "all" || row.date.startsWith(filter.month)) &&
      (filter.region === "all" || row.region === filter.region),
  );
}

function createStore<T>(initial: T) {
  let state = initial;
  const listeners = new Set<(state: T) => void>();
  return {
    get: (): T => state,
    set(next: T): void {
      state = next;
      for (const listener of listeners) listener(state);
    },
    subscribe(listener: (state: T) => void): () => void {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}
const filterStore = createStore<Filter>({ month: "all", region: "all" });

filterStore.subscribe((filter) => {
  const filtered = applyFilter(days, filter);
  renderTable(filtered);
  statusEl.textContent = `${filtered.length} of ${days.length} days shown`;
});

Accessibility that removes code

Nearly every accessibility rule deletes code rather than adding it. Use a real <button>, not a clickable <div>, and keyboard focus, Enter and Space work without a single handler. Use a <form> and pressing Enter in a field submits it, so no keydown listener is needed. Use <label for="kwh"> and clicking the label focuses the field, so no placeholder hacks are needed. Announce results β€” a count of filtered days, an error message β€” through a container with aria-live="polite" instead of relying on the user noticing a change. And after a rejected submit, move focus to the offending field with kwhInput.focus() so a keyboard user is not left guessing where the problem is.


Common mistakes

Mistake What happens The fix
Treating querySelector's generic as a runtime check getElement<HTMLInputElement>(".kwh") happily returns a HTMLSpanElement and .value is undefined Keep the generic honest, check the tag when it matters, or validate with instanceof
Using ! instead of handling null The first rename or layout change on index.html throws at module load and the whole app is blank One getElement helper that throws with the selector in the message
Writing element.value on an Element or HTMLElement TS2339: Property 'value' does not exist on type 'HTMLElement' Query the concrete interface: HTMLInputElement, HTMLSelectElement, HTMLFormElement
Building markup by concatenating innerHTML with data A region name from an API can inject markup and run script in your origin textContent for text, createElement for structure
Forgetting event.preventDefault() in a submit handler The browser navigates and reloads the page, losing all state preventDefault() first, then read new FormData(form)
Expecting FormData.get("kwh") to be a number You store a string, typeof checks pass, and arithmetic produces concatenation later input.valueAsNumber, or Number(...) with a Number.isFinite check
---

Try it

  1. Add a filter form above the table with two <select> elements named month and region, each with an <option value="all">, a submit button, and a matching <label for>.

  2. Wire it as above: a Filter object, createStore<Filter>, applyFilter, and a subscription that re-renders the table. Expected: choosing 2026-03 and Dakar leaves only the Dakar rows of that month, and the status line reads the same count twice, as in 1 of 3 days shown.

  3. Type 1e into a number field and submit. Expected: the browser's own validation speaks first for min/required, and your setCustomValidity message appears next to the field with focus still in it.

  4. Use the page with the keyboard only: Tab to the first select, arrow keys to change the value, Tab to the button, Enter. Expected: the same filtered result as with the mouse, and a visible focus ring at every step.


Key takeaways

  • Every DOM query is a claim about a document you cannot see: querySelector<T> types the claim, the null check makes it honest, and the generic on Element is what gives you .value, .href or .dataset.
  • textContent is data and innerHTML is code; the day user-supplied text reaches innerHTML, escaping is your responsibility.
  • target is where the event happened and currentTarget is what you listened on, but both are typed as EventTarget | null β€” hold the element in a variable and narrow with instanceof when you must.
  • Form values are strings. FormData gives string | File | null, valueAsNumber gives number | NaN, and the browser's constraint validation is free if you use reportValidity.
  • A typed state object plus one render function covers real interactivity, and accessible markup β€” real buttons, real labels, real forms β€” usually means writing less code, not more.

Check yourself

  1. Why does document.querySelector("#total") return a type you cannot call .value on, and what are the two ways to get the type you need?
  2. A click handler on a table receives a click on a <span> inside a <button>. What are target and currentTarget, and which one does your data- attribute reading need?
  3. Your filter is a Set-based store with three subscribers. Sketch what must happen for a typed change to reach the DOM, and name the single function that performs the render.
  4. Why is valueAsNumber preferable to Number(input.value) for a numeric field, and what does each return for an empty field?

Next

Lesson 39 β€” Calling Your Own API from the Browser