cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Read the DOM's typesTell Element, HTMLElement, and HTMLInputElement apart, and know what each one lets you use.
  • 02
    Predict a lookup's typeSay what querySelector, getElementById, querySelectorAll, and createElement give back, and when to pass a type argument.
  • 03
    Handle a missing elementDeal with the null a lookup can return, and check an element's kind for real with instanceof.

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:

Reading the quantity box (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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 a TypeError.
  • Property 'value' does not exist on type 'Element' means TypeScript only knows it's "some element", and most elements, like a paragraph, have no value.

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.

Left, the browser: a page window holds <input id="quantity">. Right, TypeScript: an editor window holds querySelector("#quantity") and the type Element | null. A note says TypeScript only sees the selector.The browser seespage<input id="quantity">the whole pageTypeScript seeseditorquerySelector("#quantity")Element | nullonly the selector
The browser has your HTML, so it finds the input box. TypeScript only has the text "#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:

Looking up by tag namePop out in the code editor (opens in a new tab)TypeScript
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.

Real-life analogyAsking a front desk for someone

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
The main idea

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:

A family tree. Element, which has id and classList, is at the top. Below it are HTMLElement, which adds hidden and dataset, and SVGSVGElement, a drawing. Below HTMLElement are HTMLInputElement, which adds value and checked, and HTMLButtonElement, which adds type and disabled.Elementid, classListHTMLElementhidden, datasetSVGSVGElementa drawingHTMLInputElementvalue, checkedHTMLButtonElementtype, disabled
Each type is a more specific kind of the one above it, and has everything that one has. An 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:

Playground: how specific the type is
A function that shows a fieldPop out in the code editor (opens in a new tab)TypeScript
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);
field is aElement
What happens
TypeScript2 errors: lines 3 and 4
When it runsquantity, false, 2
Try it yourself

Element 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.

TypeScript checks the code for real each time. Each button changes only the type on line 1. Reset goes back to Element.

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.

The element types you'll use most
TypeWhich elements it coversWhat it adds, for example
ElementEvery element, including the parts of an SVG drawing.id, classList, tagName, getAttribute(), closest()
HTMLElementEvery HTML element, like a <p>, a <div>, or an <input>.hidden, style, dataset, focus(), click()
HTMLInputElementAn <input> box.value, checked, valueAsNumber
HTMLButtonElementA <button>.type, disabled, form
HTMLSelectElementA <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:

Four lookups and their typesPop out in the code editor (opens in a new tab)TypeScript
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:

Playground: type a selector
A lookup and its typePop out in the code editor (opens in a new tab)TypeScript
const found = document.querySelector("input");//    ^?console.log(found?.tagName);
Selectorinput
What happens
TypeScript givesHTMLInputElement | null
On this page it printsINPUT
Try it yourself

input 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.

The type on line 2 comes from the real TypeScript compiler each time you type. Quotes and backslashes are left out. Reset puts "input" back.

#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:

Finding every matchPop out in the code editor (opens in a new tab)TypeScript
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:

Naming the kind with a type argumentPop out in the code editor (opens in a new tab)TypeScript
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.

Which lookup gives which type
When you writeTypeScript givesWhy
document.querySelector("input")HTMLInputElement | nullA tag name tells TypeScript the exact kind of element.
document.querySelector("#quantity")Element | nullAn id or a class could be on any element.
document.getElementById("quantity")HTMLElement | nullTypeScript'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 | nullYou named the kind. TypeScript trusts you and keeps the null.
document.createElement("li")HTMLLIElementA 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:

Playground: when the coupon box is missing
Reading the coupon boxPop out in the code editor (opens in a new tab)TypeScript
const couponBox = document.querySelector<HTMLInputElement>("#coupon");console.log(couponBox.value);
When it runstea10
What happens
TypeScript1 error: line 2
The pagehas the box
Try it yourself

Line 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.

TypeScript checks each version for real, and the output is what it does on this lesson's page, with or without its coupon box. Reset goes back to No check, with the box.

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:

Checking the kind with instanceofPop out in the code editor (opens in a new tab)TypeScript
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.

A card Element | null goes through a check, field instanceof HTMLInputElement. Straight on, it becomes HTMLInputElement and line 3 reads its value. Down a side arrow go null and any other element, to the else on line 6.Element | nullfieldinstanceofHTMLInputElement?yesHTMLInputElementline 3 reads valuenonull, or a <p>the else on line 6 runs
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.

Five ways to handle a lookup that might find nothing
How you handle itWhat happens when the element is missingUse 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 HTMLInputElementThe 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:

Building a list of pricesPop out in the code editor (opens in a new tab)TypeScript
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:

Two surprises when creating elements (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

A plus button for the quantityPop out in the code editor (opens in a new tab)TypeScript
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.

Error or fine?
  • 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");
Try it yourself
0 of 8 correct

Sort each line by what TypeScript says about it. Each line is checked on its own.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

The most expensive mix-up is treating a type argument as a check. Here the claim is wrong:

A type argument that's wrongPop out in the code editor (opens in a new tab)TypeScript
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. Only instanceof checks 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 includes null.
  • "input.quantity is a tag name, so I get HTMLInputElement." No. Only a selector that is exactly a tag name counts. Anything more gives Element.
  • "querySelectorAll needs a null check." No. It gives an empty list when nothing matches.
  • "A misspelled tag in createElement is an error." No. Any name is allowed. The type HTMLElement instead 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.

Exercise 1 · Warm-upRead a lookup's type

What type does TypeScript give total? Guess first, then point at total, or add a // ^? line under it in Edit & run.

Starter codePop out in the code editor (opens in a new tab)TypeScript
const total = document.querySelector(".total");console.log(total?.textContent);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the element type

    A size picker is made with createElement("select"). What type does TypeScript give sizePicker?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    const sizePicker = document.createElement("select");console.log(sizePicker.tagName);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead the email box

      The card has an email box holding asha@example.com. Fix both errors on line 2. What does your fixed code print?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      const emailBox = document.querySelector("#email");console.log(emailBox.value.split("@")[1]);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeHandle a box that isn't there

        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.

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        const giftNote = document.querySelector<HTMLInputElement>("#gift-note")!;console.log(giftNote.value);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeCheck the kind for real

          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.

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          const totalBox = document.querySelector<HTMLInputElement>("#total");console.log(totalBox?.value);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeBuild a cart line

            TypeScript marks lines 2 and 4. Fix both. What does your fixed code print?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            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);

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The Typing the DOM quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What type does TypeScript give quantityBox here, on a page that has <input id="quantity">?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const quantityBox = document.querySelector("#quantity");

                Choose an answer to see the explanation.

              2. Question 2 of 7Which lookup gives HTMLInputElement | null without any extra types?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does the type argument in querySelector<HTMLInputElement>("#total") check while the page runs?

                Choose an answer to see the explanation.

              4. Question 4 of 7What happens when this code runs on a page with no #gift-note box?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const giftNote = document.querySelector<HTMLInputElement>("#gift-note")!;
                console.log(giftNote.value);

                Choose an answer to see the explanation.

              5. Question 5 of 7What does this print on a page where #total is a <p>?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const 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.

              6. Question 6 of 7What type does TypeScript give button when the tag name has a typo?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const button = document.createElement("buton");

                Choose an answer to see the explanation.

              7. 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, and HTMLInputElement go 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, and getElementById gives HTMLElement.
              • querySelector and getElementById can give null; querySelectorAll gives a list and createElement gives a new element, so neither can.
              • A type argument, like as and !, is a claim TypeScript trusts. instanceof checks for real.
              • dataset holds only strings, and a misspelled tag in createElement quietly gives HTMLElement.

              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.

              CompleteFrontend Clear concepts. Working examples.