Recipe · Node and platforms

How do I type document.querySelector results in JavaScript?

  • Browser (no bundler)
  • DOM
  • querySelector
  • instanceof
  • Type casts
  • Checked with TypeScript 5.9

The problem document.querySelector('#email').value fails with 'possibly null' and 'Property value does not exist on type Element'. The element is always on the page. What is the right way to get an input element in JSDoc-checked code?

The short answer

Check the element with instanceof before you use it. Selectors that are plain tag names are typed already, and a JSDoc cast is the short form when you are sure:

checkout.js checkout.ts TypeScript 5.9.3 · checked 21 October 2026
const button = document.querySelector("button[type=submit]");
const email = document.querySelector("#email");
const form = /** @type {HTMLFormElement | null} */ (document.getElementById("checkout"));

if (!(email instanceof HTMLInputElement)) throw new Error("#email is not an input");
email.required = true;

for (const row of document.querySelectorAll("tr.cart-line")) {
  if (row instanceof HTMLTableRowElement) row.dataset.state = "ready";
}

const quantity = document.querySelector("input[name=quantity]");
if (quantity instanceof HTMLInputElement) quantity.valueAsNumber = 1;

button?.setAttribute("aria-busy", "false");
form?.reset();
console.log(email.value.trim());
const button = document.querySelector("button[type=submit]");
const email = document.querySelector("#email");
const form = document.getElementById("checkout") as HTMLFormElement | null;

if (!(email instanceof HTMLInputElement)) throw new Error("#email is not an input");
email.required = true;

for (const row of document.querySelectorAll("tr.cart-line")) {
  if (row instanceof HTMLTableRowElement) row.dataset.state = "ready";
}

const quantity = document.querySelector<HTMLInputElement>("input[name=quantity]");
if (quantity) quantity.valueAsNumber = 1;

button?.setAttribute("aria-busy", "false");
form?.reset();
console.log(email.value.trim());
Elements found by tag name, by selector with instanceof, and with a cast.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["*.js"]
}

After the instanceof HTMLInputElement check, email is an input for the rest of the file, so email.required and email.value are allowed.

Why it works

querySelector cannot know what a selector like "#email" will match, so it returns Element | null: maybe nothing, maybe any element. instanceof is a runtime check that tsc understands, and it narrows both parts at once, since null is not an instance of anything. Throwing when the check fails also narrows the rest of the scope, as the example does for email.

/** @type {HTMLFormElement | null} */ (…) is a JSDoc cast. The parentheses around the call are required; without them the comment is ignored.

The same thing in TypeScript

The TypeScript equivalent tab uses as HTMLFormElement | null for the cast, and a type argument, querySelector<HTMLInputElement>(…), which JavaScript cannot write. The instanceof checks are plain JavaScript and identical.

What goes wrong

Used as it comes, the result fails twice: it may be null (TS18047), and Element has no value or dataset (TS2339):

checkout.js Error confirmed · TypeScript 5.9.3 · checked 21 October 2026
const email = document.querySelector("#email");
console.log(email.value);error TS18047: 'email' is possibly 'null'.error TS2339: Property 'value' does not exist on type 'Element'.

const submit = document.querySelector("button");
submit.disabled = true;error TS18047: 'submit' is possibly 'null'.

for (const row of document.querySelectorAll(".cart-line")) {
  row.dataset.state = "ready";error TS2339: Property 'dataset' does not exist on type 'Element'.
}
Using the result of querySelector as it comes.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["*.js"]
}

Gotchas

  • getElementById returns HTMLElement | null. It is a little more specific than querySelector("#id"), but still not an input; narrow it the same way.
  • querySelectorAll needs dom.iterable for for…of. The examples list "dom.iterable" in lib, which makes a NodeList iterable for tsc.
  • A ! is TypeScript syntax. document.querySelector("#email")! is an error in a .js file; use a cast or a check instead (see non-null assertions).

Questions

Why does querySelector("button") know it is a button, but querySelector("#email") does not?

TypeScript's DOM types map plain tag names to element types ("button" to HTMLButtonElement). A selector with an id, a class or an attribute could match any element, so the result is Element.

Is the cast or instanceof better?

instanceof checks at runtime and narrows the type, so a wrong selector fails with a clear error where you can see it. A cast is shorter, but it is a promise tsc cannot verify, and a mistake shows up later as a property that is undefined.

Sources

Last reviewed 21 October 2026.