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.
<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.
const title = document.getElementById("title");
// ^? HTMLElement | null
console.log(title?.textContent); // Hello03querySelector: 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.
const a = document.querySelector("input"); // HTMLInputElement | null
const b = document.querySelector("#name"); // Element | null
const c = document.querySelector<HTMLInputElement>("#name"); // HTMLInputElement | null04Narrow 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.
const el = document.querySelector("#name");
if (el instanceof HTMLInputElement) {
console.log(el.value.toUpperCase()); // ASHA
}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.
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
Predict the type
In the first example, change
"input"to"#name"indocument.querySelector. Predict the new type ofinputbefore you look. Then try to keepinput?.value: why does it fail now?Elementhas novalue. Add<HTMLInputElement>afterquerySelectorto fix it. - 2
Fix the null
In the second example, change
msg.textContenttomsg?.textContent. The error disappears and the log prints the same text. Now change the id to"ghost": the program printsundefinedinstead of crashing. - 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 printsOK, 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
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
Console
Nothing logged yet.Expected console output
Hello
Asha
Go
300The 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
Console
Nothing logged yet.Expected console output
ASHA
no such element
ReadyTypeScript 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
Console
Nothing logged yet.Expected console output
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')One check covers both the wrong tag and the missing element. The function can't crash, and its return type is honestly string.
Preview
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);.
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.tslook forinterface HTMLElementTagNameMap. There is a second table,SVGElementTagNameMap, for SVG tags, anddocument.createElementaccepts any string too (it falls back toHTMLElement). - ▸
querySelector<E extends Element = Element>(selectors: string): E | nullis declared with a type parameter whose default isElement. TheEhas nothing to do with the string, so the generic form is an unchecked assertion in disguise. - ▸
instanceof HTMLInputElementcan be wrong across frames: an input from an<iframe>was built by that frame's ownHTMLInputElement, so the check returnsfalse. For cross-frame code, compareel.tagNameor useel.matches("input")with a custom type guard (Topic 4.7).
Remember this
- 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
.valueon 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 avalue,<button>a button object withdisabled, 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 whydocumentandwindowjust work in your files. - 2
The element types form a hierarchy, each level adding things. Every element is an
Element(it hasid,classList,querySelector). Every HTML element is also anHTMLElement(it addsstyle,hidden,dataset,click()). Then each tag has its own type:HTMLInputElementaddsvalueandchecked,HTMLButtonElementaddsdisabled,HTMLAnchorElementaddshref. A more specific type can be used anywhere a more general one is expected. - 3
document.getElementById("title")returns **HTMLElement | null**. It can't know which tag the id belongs to, so you only get the generalHTMLElement, andnullis there because the id may not exist on the page. The| nullis 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
document.querySelectoris smarter. When you give it a plain tag name like"input"or"button", TypeScript looks the name up in a table calledHTMLElementTagNameMapand 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 anHTMLCanvasElement(nevernull, creating always works). - 5
querySelectoris 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.gois really a<div>. The result still includesnull, because the element may be missing. - 6
To get from
Element | nullto 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 **instanceofcheck** (el instanceof HTMLInputElement) really asks the browser, and TypeScript narrows the type inside theif(Topic 4.4): it rules outnulland every other element in one step. Preferinstanceof; use a cast only when you truly know the page (and ideally validate once). - 7
Optional chaining (
title?.textContent) and the nullish operator (??) are the everyday way to handlenullwhen 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
Why does document.getElementById return HTMLElement | null?
Why does document.querySelector("input") have a better type than document.querySelector("#name")?
What is the difference between as HTMLInputElement and instanceof HTMLInputElement?
When is the non-null assertion ! acceptable?
Practice
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.
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
- ↔
instanceofcosts 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
querySelectorselectors give an exact element type and which giveElement.I can narrow an element with
instanceofand explain what a cast does not do.I can draw the chain
EventTarget→Node→Element→HTMLElement→HTMLInputElement.I can handle
nullwith?.,??or anifcheck.