Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~20 min· topic 1 of 6

Topic 13.1

Typing the DOM

In one line

The DOM is the tree of elements a browser builds from your HTML. TypeScript describes every kind of element with its own type, and because a lookup can fail, the answer is often Element | null and you must handle the null.

Think of it like this

A school with a register. You ask the teacher for "the student in seat 12". If the seat is empty, you get no student. A careful person checks before they say "hello, student". document.querySelector is that question: it looks for one element on the page, and when nothing matches the answer is **null** ("nobody there"). TypeScript makes you check before you use the answer.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

DOM
The live tree of objects the browser builds from your HTML. Scripts read and change the page through it.
Element
One tag on the page as an object, such as an input or a button.
HTMLElement
The type shared by every HTML element: it has style, hidden, dataset and click().
HTMLInputElement
The type of an <input> tag. It adds value, checked, type and so on.
null
The value that means "nothing here". A lookup that finds nothing returns it.
Selector
A CSS-style text such as #name or .go that says which element you want.
Cast
Writing as SomeType to tell TypeScript to trust you. Nothing is checked when the code runs.
instanceof
A real runtime question: was this object made by this class? TypeScript narrows the type after it.
Non-null assertion
A ! after a value. It tells TypeScript the value is not null or undefined, with no runtime check.

Step by step

01The page and the script

Every example in this phase has two parts: an HTML page and TypeScript that works with it. Here the page has a heading, a text box and a button.

The script asks the DOM for them. Whether the element exists is a fact about the page, and TypeScript can't read your HTML. That is the root of everything in this topic.

index.htmlwhole filehtml
<h1 id="title">Hello</h1>
<input id="name" value="Asha">
<button class="go">Go</button>

02getElementById: HTMLElement or null

getElementById takes only an id, and ids don't say which tag they belong to. So the answer is HTMLElement | null.

Using it directly (title.textContent) is an error, because it might be null. Optional chaining fixes it: title?.textContent.

index.tswhole filets
const title = document.getElementById("title");
//    ^? HTMLElement | null
console.log(title?.textContent); // Hello

03querySelector: tag name vs any other selector

A tag name like "input" is looked up in the tag table, so you get HTMLInputElement | null. Any other selector gives only Element | null.

Element has no value. To read the value you need a more specific type.

index.tswhole filets
const a = document.querySelector("input");   // HTMLInputElement | null
const b = document.querySelector("#name");    // Element | null
const c = document.querySelector<HTMLInputElement>("#name"); // HTMLInputElement | null

04Narrow with instanceof (a real check)

instanceof asks the browser. Inside the if, TypeScript knows the value is an input, so .value is allowed. An empty seat (null) or a wrong tag simply skips the block.

index.tswhole filets
const el = document.querySelector("#name");
if (el instanceof HTMLInputElement) {
  console.log(el.value.toUpperCase()); // ASHA
}
Narrow with instanceof (a real check)diagram
Rendering diagram…

05The hierarchy

Each type adds to the one above it. A button is an HTMLButtonElement, which is an HTMLElement, which is an Element, which is a Node, which is an EventTarget. Anything you can do to the general type, you can do to the specific one.

The hierarchydiagram
Rendering diagram…

06A cast is a promise you make, not a check

as HTMLInputElement changes what TypeScript believes. The browser still has the old object. If the element is really a <div>, .value is undefined and the bug shows up later, somewhere else.

Use instanceof when you aren't sure. Use a cast when you have just created the element yourself or the page is entirely yours.

Try it yourself

  1. 1

    Predict the type

    In the first example, change "input" to "#name" in document.querySelector. Predict the new type of input before you look. Then try to keep input?.value: why does it fail now?

    Element has no value. Add <HTMLInputElement> after querySelector to fix it.

  2. 2

    Fix the null

    In the second example, change msg.textContent to msg?.textContent. The error disappears and the log prints the same text. Now change the id to "ghost": the program prints undefined instead of crashing.

  3. 3

    Catch the lie

    In the third example, change the HTML to <input id="box" value="ok"> and keep the code as it is. It now prints OK, because the cast happens to be true. The cast never changed: only the page did. That is why a cast is a bet on the page.

Code & diagrams

What the lookups return

Hover each name in your head first: which ones can be null? Which have value? Then read the Types panel. Only createElement can never be null.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Hello
Asha
Go
300
Narrowing with instanceof, and the null you must handle

The last line is an error: msg might be null. The element does exist on this page, so the code still runs and prints Ready, but on a page without #msg it would crash. Change it to msg?.textContent.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

ASHA
no such element
Ready
A cast is a lie the browser never checks

TypeScript is satisfied, and the Console shows the crash it could not predict. Replace the cast with: const box = document.getElementById("box"); if (box instanceof HTMLInputElement) { ... }.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
A safer version: instanceof

One check covers both the wrong tag and the missing element. The function can't crash, and its return type is honestly string.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(not an input)
typed
(not an input)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the null

In the first example, change console.log(title?.textContent); to console.log(title.textContent);.

terminal
$ Types panel
── what you'll see ──
index.ts(11,13): error TS18047: 'title' is possibly 'null'.

Myth vs fact

Myth

querySelector("#name") knows the element is an input because the page says so.

Fact

TypeScript never reads your HTML. It only sees the string "#name". Only plain tag names are mapped to exact types.

Myth

! and as make the code safe.

Fact

They only silence the checker. If the element is missing or has another tag, the code still fails when it runs.

Myth

Element and HTMLElement are the same.

Fact

Element is the base of everything in the tree, including SVG. HTMLElement adds the things only HTML elements have, like style and click().

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The tag table is real and short enough to read: in lib.dom.d.ts look for interface HTMLElementTagNameMap. There is a second table, SVGElementTagNameMap, for SVG tags, and document.createElement accepts any string too (it falls back to HTMLElement).

  • ▸

    querySelector<E extends Element = Element>(selectors: string): E | null is declared with a type parameter whose default is Element. The E has nothing to do with the string, so the generic form is an unchecked assertion in disguise.

  • ▸

    instanceof HTMLInputElement can be wrong across frames: an input from an <iframe> was built by that frame's own HTMLInputElement, so the check returns false. For cross-frame code, compare el.tagName or use el.matches("input") with a custom type guard (Topic 4.7).

Remember this

  1. 1

    The problem this solves: your script has to change a page it did not create, and it can't be sure that the element it asks for exists or what kind of tag it is. TypeScript describes every kind of element, so mistakes like reading .value on a heading or using a missing element show up before the page runs. The DOM (Document Object Model) is the live tree of objects the browser builds from your HTML. Each tag becomes an object: <input> becomes an input object with a value, <button> a button object with disabled, and so on. TypeScript ships a description of all of them, the DOM lib (lib.dom.d.ts), and it is switched on by default, which is why document and window just work in your files.

  2. 2

    The element types form a hierarchy, each level adding things. Every element is an Element (it has id, classList, querySelector). Every HTML element is also an HTMLElement (it adds style, hidden, dataset, click()). Then each tag has its own type: HTMLInputElement adds value and checked, HTMLButtonElement adds disabled, HTMLAnchorElement adds href. A more specific type can be used anywhere a more general one is expected.

  3. 3

    document.getElementById("title") returns **HTMLElement | null**. It can't know which tag the id belongs to, so you only get the general HTMLElement, and null is there because the id may not exist on the page. The | null is not noise: this is the most common crash in browser code (Cannot read properties of null), and strict mode (strictNullChecks, on by default) forces you to deal with it.

  4. 4

    document.querySelector is smarter. When you give it a plain tag name like "input" or "button", TypeScript looks the name up in a table called HTMLElementTagNameMap and returns the exact type: HTMLInputElement | null. When you give it any other selector ("#name", ".go", "form input"), TypeScript can't parse CSS in its head, so it returns the general **Element | null**. document.createElement("canvas") uses the same table, and returns an HTMLCanvasElement (never null, creating always works).

  5. 5

    querySelector is generic, so you can tell it the type yourself: document.querySelector<HTMLButtonElement>(".go"). That is a promise from you, not a check: TypeScript will believe it even if .go is really a <div>. The result still includes null, because the element may be missing.

  6. 6

    To get from Element | null to something specific you have two tools. A cast (as HTMLInputElement) tells TypeScript "trust me" and checks nothing at runtime, so a wrong cast is a lie that crashes later. An **instanceof check** (el instanceof HTMLInputElement) really asks the browser, and TypeScript narrows the type inside the if (Topic 4.4): it rules out null and every other element in one step. Prefer instanceof; use a cast only when you truly know the page (and ideally validate once).

  7. 7

    Optional chaining (title?.textContent) and the nullish operator (??) are the everyday way to handle null when the element is allowed to be missing. The non-null assertion ! (document.getElementById("root")!) says "I promise it exists". It has the same weakness as a cast: it removes the error but not the crash. Use it only where the page is yours and the element is in the HTML you wrote.

Explain it without notes

01

Why does document.getElementById return HTMLElement | null?

02

Why does document.querySelector("input") have a better type than document.querySelector("#name")?

03

What is the difference between as HTMLInputElement and instanceof HTMLInputElement?

04

When is the non-null assertion ! acceptable?

Practice

01

Write function setText(id: string, text: string): void that puts text into the element with that id and does nothing if it doesn't exist. No casts.

02

Write function readNumber(id: string): number | null that reads an <input> by id and returns its value as a number, or null when the element isn't an input or the text isn't a number.

Trade-offs

  • ↔

    instanceof costs a line of code and gives a real guarantee. A cast costs nothing and gives none. For one-off scripts a cast is fine; for shared code, check.

  • ↔

    Writing querySelector<HTMLInputElement>("...") is short and reads well, but nothing verifies it. If the HTML changes, TypeScript can't tell you.

Done when you can

  • I can explain why DOM lookups return | null.

  • I can say which querySelector selectors give an exact element type and which give Element.

  • I can narrow an element with instanceof and explain what a cast does not do.

  • I can draw the chain EventTarget → Node → Element → HTMLElement → HTMLInputElement.

  • I can handle null with ?., ?? or an if check.