Skip to content
Laviyx Learn

Try

TypeScript course menu

TypeScript with the DOM: Typing Elements, Events and Forms

Use TypeScript in the browser: why querySelector returns Element | null, HTMLElement types such as HTMLInputElement and HTMLButtonElement, generic querySelector, handling null safely, typed event listeners and event.target, form handling, and DOM-related tsconfig settings.

Advanced9 min readUpdated
On this page

TypeScript ships with type definitions for the whole browser DOM (enabled with "lib": ["dom"]). They catch common front-end bugs, such as using an element that does not exist, or reading .value from something that is not an input. This lesson shows the patterns you need.

The code below is type-checked with the DOM types; to run it, compile it and load it in a web page (or use a bundler such as Vite).

querySelector can return null#

TypeScript
const heading = document.querySelector("h1");
heading.textContent = "Welcome";
Compiler error
error TS18047: 'heading' is possibly 'null'.

TypeScript cannot know whether your HTML contains an <h1>, so querySelector returns HTMLHeadingElement | null. Handle the missing case:

TypeScript
const heading = document.querySelector("h1");
if (heading) {
  heading.textContent = "Welcome";
}

// or, when a missing element is a bug, fail loudly with a clear message:
function mustFind<T extends Element>(selector: string, type: new () => T): T {
  const el = document.querySelector(selector);
  if (!(el instanceof type)) throw new Error(`Expected ${selector} to be a ${type.name}`);
  return el;
}

Element types#

Each HTML element has its own interface with the right properties:

ElementTypeUseful properties
<input>HTMLInputElementvalue, checked, valueAsNumber, files
<button>HTMLButtonElementdisabled, type
<a>HTMLAnchorElementhref
<img>HTMLImageElementsrc, alt, naturalWidth
<select>HTMLSelectElementvalue, selectedOptions
<form>HTMLFormElementelements, reset(), requestSubmit()
any elementHTMLElementstyle, dataset, hidden, focus()

When you select by tag name, TypeScript knows the type. When you select by class or id, it only knows Element, which has no value:

TypeScript
const byTag = document.querySelector("input");          // HTMLInputElement | null
const byId = document.querySelector("#email");          // Element | null
console.log(byTag?.value, byId?.value);
Compiler error
error TS2339: Property 'value' does not exist on type 'Element'.

Three ways to get the precise type:

TypeScript
// 1. Generic type argument (not checked at runtime)
const email = document.querySelector<HTMLInputElement>("#email");

// 2. Assertion (not checked at runtime)
const password = document.getElementById("password") as HTMLInputElement | null;

// 3. instanceof check (checked at runtime: the safest)
const age = document.querySelector("#age");
if (age instanceof HTMLInputElement) {
  console.log(age.valueAsNumber);
}

console.log(email?.value, password?.value);

The first two only tell TypeScript what you expect; the instanceof check verifies it while the program runs.

Typed events#

addEventListener knows which event type each event name produces:

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

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

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

event.target#

event.target is typed as EventTarget | null, because the event could come from any element (including children). Narrow it before use:

TypeScript
const list = document.querySelector("#todo");

list?.addEventListener("click", (event) => {
  const target = event.target;
  if (!(target instanceof HTMLElement)) return;
  const item = target.closest("li");
  if (item) item.classList.toggle("done");
});

const search = document.querySelector<HTMLInputElement>("#search");
search?.addEventListener("input", (event) => {
  // currentTarget is the element the listener is on
  const input = event.currentTarget as HTMLInputElement;
  console.log(input.value.trim());
});

A typed form handler#

TypeScript
interface SignupData {
  name: string;
  email: string;
  age: number;
}

function readSignup(form: HTMLFormElement): SignupData {
  const data = new FormData(form);
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    age: Number(data.get("age")),
  };
}

const form = document.querySelector<HTMLFormElement>("#signup");
form?.addEventListener("submit", (event) => {
  event.preventDefault();
  const signup = readSignup(event.currentTarget as HTMLFormElement);
  console.log(`Welcome, ${signup.name}`);
});

FormData.get() returns FormDataEntryValue | null (a string or a File), so the values are converted explicitly, which also documents what the form is expected to contain.

Creating elements#

document.createElement returns the right type for the tag:

TypeScript
const link = document.createElement("a");     // HTMLAnchorElement
link.href = "/typescript";
link.textContent = "TypeScript course";

const img = document.createElement("img");    // HTMLImageElement
img.alt = "Course logo";

document.body.append(link, img);

Configuration#

For browser code, include the DOM libraries in tsconfig.json:

JSON
{
  "compilerOptions": {
    "lib": ["es2023", "dom", "dom.iterable"]
  }
}

dom.iterable lets you loop over NodeList and other collections with for...of. In frameworks such as React, the same DOM types appear in event handler props (for example, React.ChangeEvent<HTMLInputElement>).

Practice#

Practice exercise

Write a function setupCounter(buttonSelector: string, outputSelector: string): void that finds a button and an output element (throwing a clear error if either is missing), and increases a number shown in the output each time the button is clicked. It must compile in strict mode.

Show solution
TypeScript
function setupCounter(buttonSelector: string, outputSelector: string): void {
  const button = document.querySelector<HTMLButtonElement>(buttonSelector);
  const output = document.querySelector<HTMLElement>(outputSelector);
  if (!button || !output) {
    throw new Error(`setupCounter: missing ${!button ? buttonSelector : outputSelector}`);
  }

  let count = 0;
  button.addEventListener("click", () => {
    count += 1;
    output.textContent = String(count);
  });
}

After the if check, button and output are no longer null, and because they are const, TypeScript keeps them narrowed inside the click callback.

Key takeaway#

Finished this lesson?