querySelector returns a union with null
Two problems in one line: it might not be there, and Element is the base type. The generic parameter fixes the second; an if fixes the first.
const input = document.querySelector("#q"); // Element | null
input.value; // Error: possibly null, and Element has no value
input?.value; // still no value on Element
const input = document.querySelector<HTMLInputElement>("#q"); // HTMLInputElement | null
if (input) input.value;
The element types
HTMLInputElement // .value, .checked, .files
HTMLTextAreaElement
HTMLSelectElement // .options, .selectedOptions
HTMLButtonElement // .disabled, .type
HTMLFormElement // .elements, .requestSubmit()
HTMLImageElement // .naturalWidth
HTMLDialogElement // .showModal()
HTMLElement // anything with .dataset and .style
Element // and SVG too — no .style, no .dataset
It is narrower than querySelector’s Element, which is why .dataset works on it without a cast.
Earning a cast
The cast is still a claim about your own markup — but it is made once, in a function that fails loudly, instead of at forty call sites that fail silently.
function need<T extends Element>(selector: string, root: ParentNode = document): T {
const el = root.querySelector<T>(selector);
if (!el) throw new Error(`Missing element: ${selector}`);
return el;
}
const input = need<HTMLInputElement>("#q"); // no null, no cast at the call site
Events
button.addEventListener("click", (event) => {
event.clientX; // MouseEvent: inferred from the event name
});
input.addEventListener("input", (event) => {
const target = event.target as HTMLInputElement; // target is EventTarget | null
target.value;
});
form.addEventListener("submit", (event: SubmitEvent) => { event.preventDefault(); });
An event can be dispatched on anything, so target is EventTarget | null. currentTarget is the element you attached to — and with a typed const input, reading input.value inside the closure avoids the cast entirely.
Typing a delegated handler
list.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>("button[data-id]");
if (!button) return;
remove(button.dataset.id!); // dataset values are string | undefined
});
dataset, classList and style
el.dataset.id; // string | undefined — always
el.classList.toggle("open", isOpen);
el.style.setProperty("--x", String(x));
// a typed data attribute, if you want one
const id = el.dataset["id"] ?? fail("no id");
Custom events
interface DoorAdded { door: Door }
declare global {
interface WindowEventMap {
"door:added": CustomEvent<DoorAdded>;
}
}
window.dispatchEvent(new CustomEvent<DoorAdded>("door:added", { detail: { door } }));
window.addEventListener("door:added", (event) => {
event.detail.door.name; // typed, with no cast
});
The two honest casts
Everything else — a fetch response, a message from a worker, anything parsed — is data you did not create, and a cast there is a wish. Narrow it or validate it.
- Your own markup: you wrote the HTML and you know
#qis an input. Do it in one helper that throws when it is wrong. - A library that types something too loosely. Leave a comment saying why.