Typing the DOM
Learn how TypeScript types the page: why querySelector gives Element | null, when it knows an HTMLInputElement, and how to handle a missing element.
- 01Read the DOM's typesTell
Element,HTMLElement, andHTMLInputElementapart, and know what each one lets you use. - 02Predict a lookup's typeSay what
querySelector,getElementById,querySelectorAll, andcreateElementgive back, and when to pass a type argument. - 03Handle a missing elementDeal with the
nulla lookup can return, and check an element's kind for real withinstanceof.
What TypeScript knows about your page
A product card has a quantity box, <input id="quantity">, holding 2. Every example in this lesson runs on that card. Your code finds the box and reads what's in it:
const quantityBox = document.querySelector("#quantity");// ^?console.log(quantityBox.value);Line 1 looks the box up with document.querySelector, which gives back the first element that matches a CSS selector (the Walking & searching the DOM lesson covers it). The query on line 2 shows the type TypeScript gives the result: Element | null. Line 3 reads value, and TypeScript marks it twice, on purpose:
'quantityBox' is possibly 'null'means the lookup may have found nothing. On a page without the box, line 3 would throw aTypeError.Property 'value' does not exist on type 'Element'means TypeScript only knows it's "some element", and most elements, like a paragraph, have novalue.
On this page the code still prints 2. TypeScript isn't wrong about the page; it has never seen it. It doesn't read your HTML. Everything it knows about the page comes from the DOM's type definitions, a file called lib.dom.d.ts that ships with TypeScript, and from the lookup itself. Here the lookup only says "#quantity", which could be any element.
"#quantity", so the most it can say is Element | null: some element, or nothing.Ask for a tag name instead, and check for null, and both errors go:
const quantityBox = document.querySelector("input");// ^?if (quantityBox) { console.log(quantityBox.value);}Line 1 asks for "input", a tag name, and the query on line 2 shows TypeScript now knows the kind: HTMLInputElement | null. Line 3 checks that the box was found, so line 4 may read value. It prints 2.
You call a building's front desk and ask for whoever sits at desk 12. The desk doesn't know who that is today. All it can promise is "a person, or nobody". Ask for the plumber, and they know the job of whoever comes. Either way, when someone arrives, you can still look at the badge.
- In real life: You ask for "whoever sits at desk 12"
- In TypeScript:
querySelector("#quantity") - In real life: The desk can only say "a person, or nobody"
- In TypeScript:
Element | null - In real life: You ask for "the plumber"
- In TypeScript:
querySelector("input"), a tag name - In real life: Now they know the job: "a plumber, or nobody"
- In TypeScript:
HTMLInputElement | null - In real life: You look at the badge before you hand over the pipe
- In TypeScript:
instanceof HTMLInputElement
TypeScript doesn't read your HTML. It types each DOM lookup from the lookup itself: a tag name like "input" gives an exact type like HTMLInputElement, anything else gives the general Element, and a lookup can always come back null. Before you use what you found, you check that it's there, and when it matters, what kind it is.
Element, HTMLElement, and HTMLInputElement
The DOM's types form a family. Each one is a more specific kind of the one above it, and has everything that one has, plus more. (In JavaScript these are real classes; Node properties & contents shows them.) Here are the ones you'll meet most:
HTMLInputElement is an HTMLElement, which is an Element, so a box has an id, can be hidden, and has a value.The more specific the type, the more TypeScript lets you use. Try it on a function that shows a field. The buttons change the type of its parameter, field, on line 1:
function showField(field: Element) { console.log(field.id); console.log(field.hidden); console.log(field.value);} const box = document.createElement("input");box.id = "quantity";box.value = "2";showField(box);Element2 errors: lines 3 and 4quantity, false, 2Element is any element at all, even a drawing in an SVG picture. Every element has an id, so line 2 is fine. Lines 3 and 4 get Property 'hidden' does not exist on type 'Element' and the same for value.
With Element, lines 3 and 4 are marked: not every element can be hidden or has a value. HTMLElement allows hidden, so only line 4 is left. HTMLInputElement allows all three. Line 7 makes a real input box with document.createElement, and line 10 passes it in. That's allowed with every button, because an input box is an HTMLElement and an Element too. It prints quantity, false, and 2 each time: the type only changes what TypeScript allows.
The table below lists the common types. Read each row as: the type, which elements it covers, and what it adds to the one above.
| Type | Which elements it covers | What it adds, for example |
|---|---|---|
Element | Every element, including the parts of an SVG drawing. | id, classList, tagName, getAttribute(), closest() |
HTMLElement | Every HTML element, like a <p>, a <div>, or an <input>. | hidden, style, dataset, focus(), click() |
HTMLInputElement | An <input> box. | value, checked, valueAsNumber |
HTMLButtonElement | A <button>. | type, disabled, form |
HTMLSelectElement | A <select> list. | options, selectedIndex, value |
How querySelector picks a type
TypeScript's DOM types include a list that pairs each tag name with its type: "input" with HTMLInputElement, "button" with HTMLButtonElement, and so on. When your selector is exactly a tag name, querySelector uses that list. Otherwise it falls back to Element:
const search = document.querySelector("input");// ^?const plus = document.querySelector("button");// ^?const total = document.querySelector(".total");// ^?const quantity = document.getElementById("quantity");// ^?Line 1's tag name gives HTMLInputElement | null, and line 3's gives HTMLButtonElement | null. Line 5 uses a class, .total, which could be on any element, so it gets Element | null. Line 7 uses getElementById, which TypeScript's DOM types say gives HTMLElement | null for any id. Now try your own selectors. The query on line 2 shows the type as you type:
const found = document.querySelector("input");// ^?console.log(found?.tagName);inputHTMLInputElement | nullINPUTinput is a tag name, so TypeScript looks it up in its list of tags and knows the exact type: HTMLInputElement | null. On this page it finds the quantity box.
#quantity and input#quantity find the very same box as input, but get Element | null: only a selector that is exactly a tag name counts. svg is a tag name from the list of SVG tags, so it gets SVGSVGElement | null, though this page has no picture to find.
querySelectorAll finds every match, and gives back a list called a NodeList:
const prices = document.querySelectorAll(".price");// ^?const boxes = document.querySelectorAll("input");// ^?console.log(prices.length, boxes.length);The query on line 2 shows NodeListOf<Element>: a list of elements, from a class. Line 4 shows NodeListOf<HTMLInputElement>, from a tag name. Neither type has | null: when nothing matches, you get an empty list. Line 5 prints 2 3, two prices and three input boxes on this page.
When you know the kind and the selector can't say it, you can name it yourself in angle brackets. This is a type argument, the same idea as in Generic functions:
const quantityBox = document.querySelector<HTMLInputElement>("#quantity");// ^?if (quantityBox) { console.log(quantityBox.valueAsNumber + 1);}Line 1 passes HTMLInputElement between < and >, and the query on line 2 shows HTMLInputElement | null. The | null stays, so line 3 still checks. Line 4 reads valueAsNumber, which a number box has: its contents as a number, not as text. It prints 3. A type argument is a claim, like as in Type assertions: TypeScript trusts it, and nothing checks it while the page runs.
The table below sums up the lookups. Read each row as: what you write, the type TypeScript gives, and why.
| When you write | TypeScript gives | Why |
|---|---|---|
document.querySelector("input") | HTMLInputElement | null | A tag name tells TypeScript the exact kind of element. |
document.querySelector("#quantity") | Element | null | An id or a class could be on any element. |
document.getElementById("quantity") | HTMLElement | null | TypeScript's DOM types say it finds an HTML element. |
document.querySelectorAll("input") | NodeListOf<HTMLInputElement> | A list, which is empty when nothing matches, so it's never null. |
document.querySelector<HTMLInputElement>("#quantity") | HTMLInputElement | null | You named the kind. TypeScript trusts you and keeps the null. |
document.createElement("li") | HTMLLIElement | A new element always exists, and its tag name gives its kind. |
Handling null from a lookup
Every querySelector and getElementById can give null, because a page might not have the element. TypeScript makes you decide what happens then. There are four common ways, and null and undefined introduced most of them. Here they are on the coupon box, after No check, which skips the decision. Pick a way, then untick the checkbox to take the box off the page:
const couponBox = document.querySelector<HTMLInputElement>("#coupon");console.log(couponBox.value);tea101 error: line 2has the boxLine 2 reads value without checking, and TypeScript says 'couponBox' is possibly 'null'. On this page it prints tea10. On a page without the box, it stops with a TypeError.
No check is marked: 'couponBox' is possibly 'null'. Without the box it throws a TypeError. if check and ?. and ?? keep working without the box: one runs the else, the other prints a fallback. Throw early stops with a message that says what's wrong. ! after the lookup silences TypeScript and fails just like No check, so it hides the warning without fixing anything.
All of these trust the type argument about what kind of element it is. To check the kind for real, use instanceof, which asks the element itself while the page runs (in & instanceof covers it). It handles null too:
const field = document.querySelector("#coupon");if (field instanceof HTMLInputElement) { console.log(field.value); // ^?} else { console.log("No coupon box here");}Line 1 has no type argument, so field is Element | null. Line 2 asks whether it really is an input box. Inside the if, the query on line 4 shows TypeScript agrees: const field: HTMLInputElement. Line 3 prints tea10. If the page had no coupon box, or #coupon were a paragraph, line 6 would run instead, because null instanceof HTMLInputElement is false.
instanceof looks at the real element while the page runs. Only an input box goes on to line 3, where TypeScript treats it as an HTMLInputElement. null and any other element, like a <p>, go to the else on line 6.The table below compares the ways. Read each row as: what you write, what happens when the element is missing, and when to use it.
| How you handle it | What happens when the element is missing | Use it when |
|---|---|---|
if (couponBox) { … } else { … } | The else runs. | Some pages have the element and some don't. |
couponBox?.value ?? "no coupon" | You get the fallback value. | A default value is all you need. |
if (!couponBox) throw new Error(…) | The code stops with your own message. | The page can't work without the element. |
querySelector(…)! | The code stops with a TypeError at the first use. | Almost never: it's a promise TypeScript can't check. |
field instanceof HTMLInputElement | The check is false, so that code is skipped. It's also false for the wrong kind of element. | The selector could match something that isn't an input box. |
Creating elements
document.createElement makes a new element (Creating & changing elements covers it). It uses the same list of tag names, and it never gives null, because a new element always exists:
const list = document.createElement("ul");// ^?const item = document.createElement("li");// ^?item.textContent = "Green tea";item.dataset.price = "4";list.append(item);console.log(list.outerHTML);The query on line 2 shows HTMLUListElement, a <ul> list, and line 4 shows HTMLLIElement, a list item. Line 5 sets the item's text. Line 6 stores the price in dataset, which holds an element's data- attributes (Attributes & properties covers them). Line 7 puts the item in the list, and line 8 prints <ul><li data-price="4">Green tea</li></ul>.
Two details trip people up. Both are in this example:
const badge = document.createElement("span");badge.dataset.count = 3;const price = badge.dataset.price;// ^?const typo = document.createElement("buton");// ^?Line 2 stores the number 3 in dataset, and TypeScript marks it on purpose: Type 'number' is not assignable to type 'string'. Attributes are always text, so dataset only holds strings. Line 3 reads a price that was never set, and the query on line 4 shows string | undefined. Line 5 misspells "button". That's not an error, because a page may make its own tags, like cart-badge. The query on line 6 gives the clue: HTMLElement, not HTMLButtonElement.
A quantity stepper on a product card
Put it together. The card has a plus button that adds one to the quantity and updates the total, at $4 a box. The code looks up three elements, checks them all once, and then uses them freely:
const quantityBox = document.querySelector<HTMLInputElement>("#quantity");const plusButton = document.querySelector(".plus");// ^?const totalText = document.querySelector("#total"); if (!quantityBox || !(plusButton instanceof HTMLButtonElement) || !totalText) { throw new Error("The product card is missing a part");} plusButton.addEventListener("click", () => { quantityBox.valueAsNumber += 1; totalText.textContent = `$${quantityBox.valueAsNumber * 4}`;}); plusButton.click();console.log(quantityBox.value, totalText.textContent);Line 1 names the kind with a type argument. Line 2 looks up the button by class, so the query on line 3 shows Element | null. Line 4 finds the total. Line 6 checks all three at once: the box and the total must be there, and the button must really be a <button>. If not, line 7 stops with a clear message. After line 8, TypeScript knows all three are there, even inside the function on lines 10 to 13, which runs on each click. Line 11 adds one to the number in the box, and line 12 writes the new total. Line 15 clicks the button once from code, and line 16 prints 3 $12. The next lesson types the click event itself.
document.querySelector("input")?.value;document.querySelector(".quantity")?.value;document.querySelector<HTMLInputElement>(".quantity")?.value;document.getElementById("total").textContent = "$7";const item: HTMLLIElement = document.createElement("li");const item: HTMLLIElement = document.createElement("div");console.log(document.querySelectorAll("li").length);const box: HTMLInputElement = document.querySelector("input");
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
The most expensive mix-up is treating a type argument as a check. Here the claim is wrong:
const totalBox = document.querySelector<HTMLInputElement>("#total");// ^?if (totalBox) { console.log(totalBox.value);}Line 1 says #total is an input box. The query on line 2 shows TypeScript believes it, and there are no errors. But on this page #total is a paragraph, which has no value, so line 4 prints undefined. Nothing warned you. instanceof HTMLInputElement would have caught it.
- "TypeScript reads my HTML." No. It only sees the lookup in your code. The page can have an input box with that id, and the type is still
Element | null. - "
querySelector<HTMLInputElement>checks the element." No. It's a claim, removed with the other types. Onlyinstanceofchecks while the page runs. - "An element on my page can't be
null." No. The same script often runs on pages without it, or before the element exists. TypeScript can't tell, so every lookup includesnull. - "
input.quantityis a tag name, so I getHTMLInputElement." No. Only a selector that is exactly a tag name counts. Anything more givesElement. - "
querySelectorAllneeds anullcheck." No. It gives an empty list when nothing matches. - "A misspelled tag in
createElementis an error." No. Any name is allowed. The typeHTMLElementinstead of the one you expected is the clue.
Practice exercises
Every exercise runs on this lesson's product card. Press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
What type does TypeScript give total? Guess first, then point at total, or add a // ^? line under it in Edit & run.
const total = document.querySelector(".total");console.log(total?.textContent);const total = document.querySelector(".total");// ^?console.log(total?.textContent);.total is a class, so TypeScript can only say Element | null. The query on line 2 shows it. On this page it finds the <p>, and line 3 prints $8.
A size picker is made with createElement("select"). What type does TypeScript give sizePicker?
const sizePicker = document.createElement("select");console.log(sizePicker.tagName);const sizePicker = document.createElement("select");// ^?console.log(sizePicker.tagName);"select" is a tag name, so createElement gives an HTMLSelectElement, with no null because a new element always exists. Line 3 prints SELECT.
The card has an email box holding asha@example.com. Fix both errors on line 2. What does your fixed code print?
const emailBox = document.querySelector("#email");console.log(emailBox.value.split("@")[1]);const emailBox = document.querySelector<HTMLInputElement>("#email");console.log(emailBox?.value.split("@")[1]);The type argument says it's an input box, so value exists, and ?. handles null. The box holds asha@example.com, so splitting at @ and taking the second part prints example.com.
TypeScript reports nothing here. What does the code do when it runs on this card? Then change it so it prints No gift note instead.
const giftNote = document.querySelector<HTMLInputElement>("#gift-note")!;console.log(giftNote.value);const giftNote = document.querySelector<HTMLInputElement>("#gift-note");console.log(giftNote?.value ?? "No gift note");The lookup gives null, and the ! only silenced TypeScript, so reading value throws a TypeError. With ?. and ??, a missing box gives the fallback, and it prints No gift note.
The code compiles, but its claim is wrong. What does it print? Then rewrite it with instanceof so it prints #total isn't an input box.
const totalBox = document.querySelector<HTMLInputElement>("#total");console.log(totalBox?.value);const totalBox = document.querySelector("#total");if (totalBox instanceof HTMLInputElement) { console.log(totalBox.value);} else { console.log("#total isn't an input box");}The type argument is a claim nobody checks, so the starter prints undefined. instanceof asks the real element. A paragraph isn't an input box, so the else prints #total isn't an input box.
TypeScript marks lines 2 and 4. Fix both. What does your fixed code print?
const list = document.createElement("ul");const item: HTMLDivElement = document.createElement("li");item.textContent = "Cake";item.dataset.price = 4;list.append(item);console.log(list.outerHTML);const list = document.createElement("ul");const item: HTMLLIElement = document.createElement("li");item.textContent = "Cake";item.dataset.price = "4";list.append(item);console.log(list.outerHTML);A list item is an HTMLLIElement, not an HTMLDivElement, and dataset holds only strings. With both fixed, line 6 prints <ul><li data-price="4">Cake</li></ul>.
Check your understanding
For each question, ask what TypeScript can know from the code alone, and what the page has when the code runs. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What type does TypeScript give
quantityBoxhere, on a page that has<input id="quantity">?Read the code, then predictconst quantityBox = document.querySelector("#quantity");Choose an answer to see the explanation.
Question 2 of 7Which lookup gives
HTMLInputElement | nullwithout any extra types?Choose an answer to see the explanation.
Question 3 of 7What does the type argument in
querySelector<HTMLInputElement>("#total")check while the page runs?Choose an answer to see the explanation.
Question 4 of 7What happens when this code runs on a page with no
#gift-notebox?Read the code, then predictconst giftNote = document.querySelector<HTMLInputElement>("#gift-note")!; console.log(giftNote.value);Choose an answer to see the explanation.
Question 5 of 7What does this print on a page where
#totalis a<p>?Read the code, then predictconst total = document.querySelector("#total"); if (total instanceof HTMLInputElement) { console.log(total.value); } else { console.log("Not an input box"); }Choose an answer to see the explanation.
Question 6 of 7What type does TypeScript give
buttonwhen the tag name has a typo?Read the code, then predictconst button = document.createElement("buton");Choose an answer to see the explanation.
Question 7 of 7What does
document.querySelectorAll(".price")give back when nothing on the page matches?Choose an answer to see the explanation.
Key takeaways
- TypeScript doesn't read your HTML. It types each lookup from the lookup itself, using the DOM's type definitions.
Element,HTMLElement, andHTMLInputElementgo from general to specific. The more specific the type, the more you can use.- A selector that is exactly a tag name gives that tag's type. Anything else gives
Element, andgetElementByIdgivesHTMLElement. querySelectorandgetElementByIdcan givenull;querySelectorAllgives a list andcreateElementgives a new element, so neither can.- A type argument, like
asand!, is a claim TypeScript trusts.instanceofchecks for real. datasetholds only strings, and a misspelled tag increateElementquietly givesHTMLElement.
Remember the one-liner.
TypeScript knows your lookups, not your page: name the kind, handle the null, and check with instanceof when it matters.
Coming next: Typing events. It types what your handlers receive: MouseEvent, KeyboardEvent, and the difference between event.target and event.currentTarget.