Mert Özen Further With Every Line
Frontend TypeScript Basics — #14

TypeScript in the Browser: DOM Types, querySelector and Events

Mert Özen Nov 3, 2026 7 min 1 views
TypeScript in the Browser: DOM Types, querySelector and Events

We look at the lib.dom types, what querySelector returns and its generic form, element types like HTMLInputElement, event types and the event.target trap, then run tsc output on a page as a module script.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

A page has a button and an input whose id is email. Two lines you've written hundreds of times in the JavaScript series:

const button = document.querySelector("button");
const email = document.querySelector("#email");

console.log(email.value);

Two questions: which type does TypeScript infer for button and email? Does the last line compile? Hint: the two variables don't have the same type.

lib.dom: The Browser's Type Map

In the tsconfig post we saw that when you don't write lib, TypeScript adds the browser's dom types next to the ECMAScript types. These types ship with the TypeScript package and are generated from web standards: there's a ready-made type for document, window, localStorage, fetch, every HTML element and every event. If your editor can suggest hundreds of properties and methods the moment you type document., without you installing any package, this file is the reason.

These types form a hierarchy. At the top is EventTarget; below it Node, then Element, then HTMLElement, and at the bottom concrete elements such as HTMLInputElement and HTMLButtonElement. The further down you go, the more the type knows: Element has no value, because not every element has a value; HTMLInputElement does.

Let's make one thing clear up front: lib.dom describes the browser's APIs, but it knows nothing about the HTML on your page. Nothing tells TypeScript that an element called #email exists, or that it's an input. That's the reason for all the careful checks in this post.

What Does querySelector Return?

It depends on the selector. If the selector is just a tag name, TypeScript looks it up in a table and gives you the right element type. In every other case, all you get is the general Element:

const heading = document.querySelector("h1"); // HTMLHeadingElement | null
const items = document.querySelectorAll("li"); // NodeListOf<HTMLLIElement>
const logo = document.querySelector("svg"); // SVGSVGElement | null
const app = document.getElementById("app"); // HTMLElement | null
const card = document.querySelector(".card"); // Element | null

TypeScript can't know which element a class selector like ".card" will find, so it says Element. Seeing | null in the results is no accident either: if querySelector finds nothing, it returns null, and strict mode doesn't hide that from you. querySelectorAll, on the other hand, always returns a list, possibly an empty one, so it has no null.

Here's the answer to the opening question: button is HTMLButtonElement | null; email is Element | null. The last line doesn't compile, and tsc reports two errors:

error TS18047: 'email' is possibly 'null'.
error TS2339: Property 'value' does not exist on type 'Element'.

Getting to the Right Element Type

You have an Element | null and you need an HTMLInputElement. There are three ways, and the difference between them matters. The first two look like this:

const email = document.querySelector<HTMLInputElement>("#email");
// type: HTMLInputElement | null

const password = document.querySelector("#password") as HTMLInputElement;
// type: HTMLInputElement

The first is the generic form of querySelector. The type inside the angle brackets tells the function "return this element type to me." We'll explain generics from scratch in the first post of the intermediate series; for now, read it as a setting where you pick the return type. The null possibility stays in place, which is a good thing. The second is the as we saw in the type assertion post. It changes the type and also silently removes null.

Their shared problem is that neither checks anything at runtime. If #email is actually a textarea, your code compiles but runs on a wrong assumption. The third way closes that gap:

const email = document.querySelector("#email");

if (email instanceof HTMLInputElement) {
  console.log(email.value.trim());
} else {
  console.error("Missing #email input");
}

The instanceof we saw in the narrowing post does two jobs here at once: it rules out null, and it verifies at runtime that the element really is an input. Inside the if block, the type of email is HTMLInputElement. This is my default; on small pages I wrote myself and am sure about, the generic form is acceptable too.

HTMLInputElement and Its Relatives

Once you reach the right type, the editor shows you everything specific to that element. On an input: value, valueAsNumber, checked, disabled; on HTMLSelectElement: value and selectedIndex; on HTMLFormElement: elements and reset(). Pay special attention to one of them:

const price = document.querySelector("#price");
const count = document.querySelector("#count");

if (price instanceof HTMLInputElement && count instanceof HTMLInputElement) {
  const wrong = price.value + count.value; // string: "10" + "2" → "102"
  const right = price.valueAsNumber + count.valueAsNumber; // number: 12
  console.log(wrong, right);
}

Even on an input with type="number", value is always a string. TypeScript says so explicitly in its type, so the type of wrong becomes string, and a quick hover shows you that you're concatenating instead of adding without realizing it. If you want a number, use valueAsNumber; and don't forget it returns NaN for an empty input.

Event Types

When you pass the event name to addEventListener as a string, TypeScript infers the type of the callback's parameter from that name. You don't need to write an annotation at all:

const button = document.querySelector("button");

button?.addEventListener("click", (event) => {
  console.log(event.clientX, event.clientY); // event: PointerEvent
});

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    console.log("Closed"); // event: KeyboardEvent
  }
});

"keydown" gives you KeyboardEvent, "submit" gives SubmitEvent, and "input" gives the general Event. For "click", older sources say MouseEvent; in the lib.dom of TypeScript 7.0.2 the type is PointerEvent. Since PointerEvent extends MouseEvent, all the familiar properties like clientX are still there.

When you write the callback as a separate function, this inference doesn't work, because the function doesn't know at its definition which event it will be attached to. Then you annotate the parameter, and TypeScript catches a wrong match:

function handleKey(event: KeyboardEvent) {
  if (event.key === "Escape") {
    console.log("Closed");
  }
}

document.addEventListener("keydown", handleKey);

// Error: No overload matches this call.
document.addEventListener("click", handleKey);

The lower lines of the error message explain why: Type 'Event' is missing the following properties from type 'KeyboardEvent': altKey, charCode, code, ctrlKey, and 17 more. A click event has no key property, and TypeScript tells you before anything runs.

Common Pitfalls

The classic pitfall is event.target. You want to put a single listener on a list and find out which li was clicked:

const list = document.querySelector("ul");

list?.addEventListener("click", (event) => {
  // Error: 'event.target' is possibly 'null'.
  // Error: Property 'closest' does not exist on type 'EventTarget'.
  const item = event.target.closest("li");
});

The type of event is PointerEvent, but the type of event.target is just EventTarget | null. Here's why: whatever fires an event doesn't have to be an element; window, document or an XMLHttpRequest can dispatch events too. In the general addEventListener signature, TypeScript can't know what the target is. The fix is instanceof again:

const list = document.querySelector("ul");

list?.addEventListener("click", (event) => {
  if (event.target instanceof Element) {
    const item = event.target.closest("li");
    console.log(item?.textContent);
  }
});

The second pitfall is trusting the generic form too much. document.querySelector<HTMLInputElement>("button") compiles without errors; TypeScript only requires the type to be an Element, and doesn't check whether it's consistent with the selector. So this is an assertion too, just a more polite way of writing one.

The third pitfall is adding ! to the end of every querySelector. When the page's HTML changes, or the script runs earlier than you expected, the error you silenced at compile time comes back at runtime as a TypeError.

Compiling with tsc and Running It on a Page

Browsers don't run TypeScript; we need to give the page JavaScript. Let's build a small price calculator. The folder structure looks like this:

price-calculator/
  index.html
  tsconfig.json
  src/
    format.ts
    main.ts

The settings from the tsconfig post, adapted for the browser:

{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}

We didn't write module or lib; the default esnext modules and dom types are exactly what we want for the browser. rewriteRelativeImportExtensions, which came with TS 5.7, turns the .ts extension in imports into .js in the output; the browser asks for the file that actually exists in the dist folder. Here are our two source files:

// src/format.ts
export function formatPrice(amount: number): string {
  return `$${amount.toFixed(2)}`;
}
// src/main.ts
import { formatPrice } from "./format.ts";

const unitPrice = 12.5;
const quantity = document.querySelector("#quantity");
const total = document.querySelector("#total");

if (quantity instanceof HTMLInputElement && total instanceof HTMLOutputElement) {
  quantity.addEventListener("input", () => {
    total.value = formatPrice(quantity.valueAsNumber * unitPrice);
  });
}

And the page:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Price Calculator</title>
    <script type="module" src="./dist/main.js"></script>
  </head>
  <body>
    <input id="quantity" type="number" value="1" min="1">
    <output id="total">$12.50</output>
  </body>
</html>

When you run npx tsc in the folder, dist/main.js and dist/format.js are created; the import line in main.js now points to "./format.js". Even though we put the script in <head>, we can find the elements, because, as we saw in the JavaScript series, type="module" scripts wait until the HTML has been parsed. You'll also remember from that series: if you open the page via file://, the module won't load; use a local server such as Live Server. If you run npx tsc --watch while developing, the output is refreshed every time you save.

A Small Experiment

★☆☆ Predict the type: document.querySelector("form"), document.querySelector("#signup") and document.querySelectorAll("button"). Verify by hovering in your editor.

★★☆ Clear the input in the price calculator; what do you see in total? Fix the code so it shows $0.00 when valueAsNumber is NaN.

★★☆ Does it compile: document.addEventListener("submit", (event: KeyboardEvent) => {});? If you get an error, read the message and find the right event type.

★★★ Write code that logs the text of whichever li inside a ul is clicked, using a single listener and event.target, without any as or !.

The TypeScript Basics series ends here. You can now read types, write your own, configure the compiler and use TypeScript in the browser. In this post, the angle-bracket form of querySelector opened the door to generics a crack. In the TypeScript Intermediate series we'll walk through that door: we'll explain generics from scratch, then talk about classes, utility types like Partial and Pick, writing your own type guards, and modules.