The DOM and events

Type what comes back from the page, and cast only where you have earned it.

13of 14
≈ 12Min read
8Parts

querySelector returns a union with null

Two problems in one line: it might not be there, and Element is the base type. The generic parameter fixes the second; an if fixes the first.

the first thing strict mode makes you handle
const input = document.querySelector("#q");          // Element | null

input.value;              // Error: possibly null, and Element has no value
input?.value;             // still no value on Element

const input = document.querySelector<HTMLInputElement>("#q");   // HTMLInputElement | null
if (input) input.value;

The element types

the ones you will type most days
HTMLInputElement      // .value, .checked, .files
HTMLTextAreaElement
HTMLSelectElement     // .options, .selectedOptions
HTMLButtonElement     // .disabled, .type
HTMLFormElement       // .elements, .requestSubmit()
HTMLImageElement      // .naturalWidth
HTMLDialogElement     // .showModal()
HTMLElement           // anything with .dataset and .style
Element               // and SVG too — no .style, no .dataset
getElementById is already HTMLElement | null

It is narrower than querySelector’s Element, which is why .dataset works on it without a cast.

Earning a cast

The cast is still a claim about your own markup — but it is made once, in a function that fails loudly, instead of at forty call sites that fail silently.

a helper that does the check once
function need<T extends Element>(selector: string, root: ParentNode = document): T {
  const el = root.querySelector<T>(selector);
  if (!el) throw new Error(`Missing element: ${selector}`);

  return el;
}

const input = need<HTMLInputElement>("#q");     // no null, no cast at the call site

Events

the listener knows its own event type
button.addEventListener("click", (event) => {
  event.clientX;          // MouseEvent: inferred from the event name
});

input.addEventListener("input", (event) => {
  const target = event.target as HTMLInputElement;   // target is EventTarget | null
  target.value;
});

form.addEventListener("submit", (event: SubmitEvent) => { event.preventDefault(); });
Why event.target needs a cast

An event can be dispatched on anything, so target is EventTarget | null. currentTarget is the element you attached to — and with a typed const input, reading input.value inside the closure avoids the cast entirely.

Typing a delegated handler

closest, with the type it returns
list.addEventListener("click", (event) => {
  const button = (event.target as Element).closest<HTMLButtonElement>("button[data-id]");
  if (!button) return;

  remove(button.dataset.id!);      // dataset values are string | undefined
});

dataset, classList and style

three small types worth knowing
el.dataset.id;                    // string | undefined — always
el.classList.toggle("open", isOpen);
el.style.setProperty("--x", String(x));

// a typed data attribute, if you want one
const id = el.dataset["id"] ?? fail("no id");

Custom events

a typed detail, end to end
interface DoorAdded { door: Door }

declare global {
  interface WindowEventMap {
    "door:added": CustomEvent<DoorAdded>;
  }
}

window.dispatchEvent(new CustomEvent<DoorAdded>("door:added", { detail: { door } }));

window.addEventListener("door:added", (event) => {
  event.detail.door.name;        // typed, with no cast
});

The two honest casts

Everything else — a fetch response, a message from a worker, anything parsed — is data you did not create, and a cast there is a wish. Narrow it or validate it.

  • Your own markup: you wrote the HTML and you know #q is an input. Do it in one helper that throws when it is wrong.
  • A library that types something too loosely. Leave a comment saying why.

Practice