Type querySelector in TypeScript

Learn how to type querySelector and querySelectorAll in TypeScript using generics, tag name inference, and null narrowing.

7 min read

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.

typescripttypescript
const el = document.querySelector('.card');
//    ^? const el: Element | null

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

typescripttypescript
const list = document.querySelector('ul');
//    ^? const list: HTMLUListElement | null
 
const input = document.querySelector('input');
//    ^? const input: HTMLInputElement | null

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

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

typescripttypescript
const nameInput = document.querySelector<HTMLInputElement>('#name');
//    ^? const nameInput: HTMLInputElement | null
 
const submitBtn = document.querySelector<HTMLButtonElement>('.submit-btn');
//    ^? const submitBtn: HTMLButtonElement | null

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

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

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

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

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

typescripttypescript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why does querySelector return Element or null by default?

querySelector accepts any CSS selector string, so TypeScript cannot know at compile time which specific element will be found. It returns Element (the broadest type that fits any HTML or SVG element) and marks the result as nullable because the selector might not match anything at runtime.

Should I always use a generic with querySelector?

Not always. If you use a tag name selector like querySelector('ul'), TypeScript infers the type automatically from the tag name. Use the generic only when the tag name is not enough, like for class or ID selectors.

How do I type querySelectorAll results?

querySelectorAll returns a NodeListOf containing all matching elements. If you pass a tag name, TypeScript infers the element type. For class selectors, pass a generic like NodeListOf with your expected element type.

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.