Type querySelector in TypeScript
Learn how to type querySelector and querySelectorAll in TypeScript using generics, tag name inference, and null narrowing.
document.querySelector returns the first element that matches a CSS selector, or null if no element matches. TypeScript cannot know at compile time which element your selector will find, so it gives you a broad type by default. You can narrow that type in three ways: by using a tag name selector, by passing a generic type argument, or by checking with instanceof at runtime.
The default return type
Without any type help, querySelector returns the broadest possible element type: Element or null. Element is the base interface for all DOM elements. It has classList, setAttribute, and querySelector, but it does not have HTML-specific properties like style or value.
const el = document.querySelector('.card');
// ^? const el: Element | nullIf you try to access style on this result, TypeScript will complain because style only exists on HTMLElement, which is a more specific type than Element. You need to narrow the type first, either through the selector or through a runtime check.
Tag name inference
When you pass a plain tag name as the selector, TypeScript automatically infers the specific element type. It looks up the tag name in a mapping called HTMLElementTagNameMap that connects tag names to their element interfaces:
const list = document.querySelector('ul');
// ^? const list: HTMLUListElement | null
const input = document.querySelector('input');
// ^? const input: HTMLInputElement | nullThe string "input" maps to HTMLInputElement, "ul" maps to HTMLUListElement, and so on. This works for all standard HTML tags. The null is still there because the element might not exist, but the element type is already correct. You only need to handle null:
const input = document.querySelector('input');
if (input !== null) {
console.log(input.value); // OK, HTMLInputElement has .value
}Using a generic for class and ID selectors
Tag name inference only works when the selector starts with a bare tag name. For class selectors, ID selectors, and attribute selectors, you tell TypeScript which element type you expect by passing a generic type argument:
const nameInput = document.querySelector<HTMLInputElement>('#name');
// ^? const nameInput: HTMLInputElement | null
const submitBtn = document.querySelector<HTMLButtonElement>('.submit-btn');
// ^? const submitBtn: HTMLButtonElement | nullThe generic tells TypeScript "I expect the matched element to be an input." The return type becomes HTMLInputElement or null instead of the default Element or null. Use this when you control the HTML and know the selector will match a specific element type.
What happens without a generic
If you use a class selector without a generic, you get the default Element or null. To access element-specific properties, narrow with instanceof after the query:
const el = document.querySelector('.user-name');
if (el instanceof HTMLInputElement) {
console.log(el.value); // OK
}The instanceof check is safer than a generic because it verifies the element type at runtime. Use it when you cannot be sure which element the selector will match, or when the same selector could match different element types on different pages.
Typing querySelectorAll
querySelectorAll returns a NodeListOf containing all matching elements. Like querySelector, tag name selectors infer the correct element type automatically, and class selectors benefit from a generic:
const items = document.querySelectorAll('li');
// ^? const items: NodeListOf<HTMLLIElement>
const buttons = document.querySelectorAll<HTMLButtonElement>('.action-btn');
// ^? const buttons: NodeListOf<HTMLButtonElement>
buttons.forEach((btn) => {
btn.disabled = true; // OK, HTMLButtonElement has .disabled
});NodeListOf behaves like a typed array in forEach loops. The callback parameter gets the correct element type automatically, so you have full autocomplete inside the loop body.
Null handling patterns
Every querySelector call can return null. Here are three patterns to handle it. Pick the one that matches how critical the element is to your code.
Early return for elements that are not essential to the rest of the function:
const modal = document.querySelector<HTMLDivElement>('#modal');
if (modal === null) return;
modal.style.display = 'block';The return on the null check stops the function before it reaches modal.style.display, so TypeScript narrows modal to HTMLDivElement for the rest of the function body. This pattern works well inside event handlers and setup functions where a missing modal simply means there is nothing to show.
Throw an error for required structural elements that must exist for the page to work:
const root = document.querySelector<HTMLDivElement>('#root');
if (root === null) throw new Error('#root element is missing');
root.classList.add('loaded');Throwing is the right choice for elements your app cannot function without, like a root container. A missing #root almost always means a markup or script loading bug, so failing loudly is more useful than failing silently.
Optional chaining for decorative elements that may or may not be present on the page:
const tooltip = document.querySelector<HTMLDivElement>('.tooltip');
tooltip?.classList.add('visible');The ?. operator skips the call entirely when tooltip is null, so nothing happens if the element is missing. Use this for optional UI pieces where a missing element should not stop the rest of the page from working.
Pick the pattern that matches the element's importance. Required structural elements should throw or return early. Optional decorative elements work well with optional chaining.
Common mistakes
Forgetting null after a generic is the most frequent error. The generic narrows the element type, but null is still part of the union. Always guard before accessing properties.
Using the wrong generic type is also common. If you pass HTMLInputElement but the selector matches a div, TypeScript will let you access .value at compile time, but the property will not exist on a div at runtime. Always match the generic to the actual HTML element in your page.
See Type DOM Elements in TypeScript for the full DOM type hierarchy. See Type Form Elements in TypeScript for how to type form fields after selecting them.
Rune AI
Key Insights
- querySelector returns Element or null by default because any selector is valid and the element might not exist.
- Pass a tag name like 'input' and TypeScript infers the specific element type automatically.
- Use a generic when the selector is a class or ID to tell TypeScript which element type you expect.
- Always check for null before accessing element properties.
- querySelectorAll returns NodeListOf, which you can type with a generic for precise iteration.
Frequently Asked Questions
Why does querySelector return Element or null by default?
Should I always use a generic with querySelector?
How do I type querySelectorAll results?
Conclusion
TypeScript gives you three ways to type querySelector results: tag name inference for simple selectors, generic type arguments for precise element types, and runtime narrowing with instanceof. Always handle null before accessing properties.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.