TypeScript from the ground up

The page, typed

Queries that know what they found, events that know what happened, and the one check the compiler insists on before any element is touched.

Part 10 of 13

Intermediate15 min readBack to Courses

A query might find nothing

document.querySelector("#name") returns Element | null, and the union is the truth: the element may not be on this page. Every honest DOM function starts by dealing with the null, because the alternative is the classic crash of a page that changed its markup.

The generic form names what the selector will return: document.querySelector<HTMLInputElement>("#name"). Now the result is a typed input — value and checked are there — or null, which is the part still to handle.

Typed query, honest check, typed event

const input = document.querySelector<HTMLInputElement>("#name");
const out = document.querySelector<HTMLParagraphElement>("#out");

if (!input || !out) {
  throw new Error("The page is missing its name field");
}

input.addEventListener("input", (event) => {
  const field = event.target as HTMLInputElement;
  out.textContent = "Hello, " + field.value;
});

Events carry their own type

The callback of addEventListener is typed by the event name: an "input" handler receives an Event, and its target is typed as the general EventTarget — the listener system is shared by every event, so it cannot assume more.

Two ways to get the specific element back: name the listener generic (addEventListener<"input">) or cast the target on the line that uses it, as above. Either way, the cast is a claim you make once, next to the code that depends on it.

Null first, then work

Once the check has run, the compiler narrows input to HTMLInputElement for the rest of the function — no ! at every use, no hoping. Throwing the error at the top is the shortest version: it names the missing element and stops.

This is the pattern every script in the JavaScript course already follows: find, check, listen. TypeScript is what writes the check down. It is also what keeps the check there: delete the guard and the next line stops compiling.

The event is not always the element

event.target is the element that dispatched the event, which may be a child of the element you listened on. event.currentTarget is the element the listener was attached to, so it is the one whose handler is running. For a list where each row is clicked, the target is the row and the current target is the list.

Both properties are typed as EventTarget | null by the shared event interface, so neither arrives as a specific element without help. What the listener does give you is the element’s type in this, when the callback is a function expression rather than an arrow; otherwise cast once, as the first example does.

Text, not markup

textContent sets text and nothing else; innerHTML parses a string as markup. For a value a reader typed, textContent is the safe default — it cannot become a tag, a script or an event handler.

The type system cannot tell the two apart: both accept a string. That is a reminder that types check shapes, not intentions, and the safe API is a choice you make at the line.

What the DOM types describe

The DOM types come from a library file that describes every element, event and method the browser offers. HTMLInputElement is not a class you can build; it is a description of the object the browser hands you, including value, checked, disabled and the events it fires. The compiler reads that description and checks your use of the element against it.

That is why the generic argument matters. querySelector<HTMLInputElement>("#name") does not change what the selector matches; it tells the compiler which description to apply. If the selector finds a div, the object is still a div at run time, and the annotation was a claim that happened to be wrong.

What goes wrong

The most common error is the one the compiler warns about and the code silences. The element is null, input!.value compiles, and the page throws when the field is renamed — which is why the if (!input) is not boilerplate.

The second is trusting event.target. Click a <span> inside a button and the target is the span, so reading .value or .dataset from it gives undefined rather than the value you expected. When the listener is on the container, read currentTarget, or walk up from the target with closest().

Three DOM mistakes the compiler will not catch for you. A non-null assertion ! after a query turns a real missing element into a run-time crash. Naming a query HTMLInputElement does not make the selector find one. And innerHTML with text from a reader is an injection hole, however well typed the surrounding code is.

Try these next

  • Add a button that clears the field and put the listener on the button, not the input.
  • Compare event.target and event.currentTarget on a click inside a button, and explain which one the handler belongs to.
  • Replace one innerHTML assignment with textContent and try to type markup into it.

A typed greeting

Name the element type each query returns, stop with a clear error when either is missing, then type the field the event delivers.

Live Preview
Console output will appear here...

3 left to do

  • The input query names its element type
  • The output query names its element type
  • Both elements are checked before use

The element type goes in angle brackets on the query: querySelector<HTMLInputElement>("#name"). After a check like if (!input || !out) throw new Error(...), both are narrowed for the rest of the code.

A typed click counter

Name the button and output types in the queries, point the button query at its real id, and stop with a clear error if either element is missing.

Live Preview
Console output will appear here...

3 left to do

  • The button query names its element type
  • The output query names its element type
  • Both elements are checked before use

The button has id="count" in the page, not #counter. querySelector<HTMLButtonElement>("#count") returns the button or null; after a guard, the listener is typed.

Name the element type in the query — querySelector<HTMLInputElement> — rather than casting the result later. The type starts at the line that found the element, where the selector is still in view.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

Why does querySelector return Element | null rather than Element?