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:
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());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):
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'.
}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
getElementByIdreturnsHTMLElement | null. It is a little more specific thanquerySelector("#id"), but still not an input; narrow it the same way.querySelectorAllneedsdom.iterableforfor…of. The examples list"dom.iterable"inlib, which makes aNodeListiterable for tsc.- A
!is TypeScript syntax.document.querySelector("#email")!is an error in a.jsfile; 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
- Document: querySelector() method · MDN Web Docs
- DOM Manipulation (TypeScript and the DOM) · TypeScript handbook
- JSDoc Reference (casts) · TypeScript handbook
Last reviewed 21 October 2026.