Typing the DOM

Expert · 18 min read · ▶ live playground · ✦ checkpoint

TypeScript can type-check browser DOM code without a framework, a package, or a special import. The catch is that DOM methods tell the truth: a selector might find nothing, and the element it finds may be only a generic Element until you prove more.

That honesty is exactly what you want in real browser code. The page can change, markup can drift, and selectors can be wrong; TypeScript makes you handle those possibilities before the script touches the live document.

The DOM types are already there

Open a browser TypeScript file and names like document, HTMLElement, HTMLInputElement, and MouseEvent already have types. They come from a declaration file named lib.dom.d.ts, one of TypeScript's standard library declaration files.

You do not import it. Your tsconfig chooses libraries with the lib option, and browser projects include DOM types. The declarations describe APIs the browser provides at runtime:

  • document.querySelector(...)
  • element.textContent
  • HTMLInputElement.value
  • HTMLButtonElement.disabled

The pattern should feel familiar from declaration files and package types: TypeScript is not adding browser behavior. It is describing JavaScript objects that already exist when your code runs in a page.

querySelector returns two truths

The everyday DOM entry point is document.querySelector. It accepts a CSS selector string and returns the first matching element. TypeScript types that result as Element | null.

Both halves matter:

  • Element because many selectors could match many element kinds: a p, button, input, svg, or something else.
  • null because the selector might match nothing.

If you skip the null check, TypeScript stops you before the browser throws:

const statusLine = document.querySelector("#status");
 
statusLine.textContent = "Ready";

playground.ts:3:1 - error TS18047: 'statusLine' is possibly 'null'.

This is not TypeScript being fussy. It is naming a real browser outcome. If someone renames id="status" to id="message", the selector returns null, and null.textContent crashes. The boring guard is the professional move. In read-only reference code, it looks like this:

function markReady(page: Document): void {
  const statusLine = page.querySelector("#status");
 
  if (statusLine !== null) {
    statusLine.textContent = "Ready";
  }
}

After the if, the checker has eliminated null. You already know this move from narrowing; browser code simply makes it unavoidable.

Try it - typed DOM edits

Run this page and look at the preview, not just the console. The TypeScript tab changes the status text, reads the input only after proving it is an HTMLInputElement, and uses a selector generic for a button whose markup this example owns.

typescript + dom — live previewsandboxed
Run rebuilds the page

There are three different DOM typing moves in that one script.

First, statusLine is only used after statusLine !== null. The type starts as Element | null; the guard leaves Element, and textContent plus classList are safe on Element.

Second, nameField instanceof HTMLInputElement narrows from Element | null to HTMLInputElement. That one check handles both possibilities at once: null is not an instance of HTMLInputElement, and a random paragraph is not an input either. Inside the block, .value is safe because the runtime constructor proved the specific element class.

Third, the button uses document.querySelector<HTMLButtonElement>("#save-profile"). That generic is a deliberate helper: it tells TypeScript which element type you expect back from a selector you control. The return type is still HTMLButtonElement | null, so the null guard stays. The generic removes the need for an instanceof check when the HTML and selector live together and are easy to review.

When to use the generic

Use the generic when the selector is boring and owned by your code:

function findSaveButton(page: Document): HTMLButtonElement | null {
  return page.querySelector<HTMLButtonElement>("#save-profile");
}

That is a promise about your markup. If the HTML says <button id="save-profile">, the promise is local and readable. If the selector comes from user input, a CMS, a third-party widget, or a page you do not control, prefer runtime proof:

function readDisplayName(page: Document): string | null {
  const field = page.querySelector("#display-name");
 
  if (field instanceof HTMLInputElement) {
    return field.value;
  }
 
  return null;
}

The difference is the same boundary rule you have used all course: static promises are great for code you own; runtime checks are required when reality can disagree.

One more detail: tag-name selectors often get better types without a generic. document.querySelector("button") can infer HTMLButtonElement | null because button is a known tag name in the DOM declarations. But "#save-profile" is just a CSS selector string. TypeScript cannot parse your HTML file and prove that id belongs to a button, so you either narrow at runtime or supply the generic intentionally.

The browser still runs JavaScript

Everything TypeScript knows here erases before runtime. HTMLInputElement survives because it is a real browser constructor. The generic in querySelector<HTMLButtonElement> does not survive; it is only a type argument for the checker.

That makes the rule simple: use TypeScript to make DOM assumptions visible, then write JavaScript that still behaves if the page changes. Check for null. Use instanceof when element kind matters at runtime. Use the selector generic only when the selector and markup are yours.

Next lesson, the DOM gets more interactive: events, listeners, and why the element that fired an event is not always the element your listener is attached to.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion