Browser code is where null checks and element types matter most: a selector may match nothing, an Element may not have a .value, and an event's target could be any node. TypeScript ships complete typings for the DOM, so with a few habits you get autocompletion for every element and event and compile-time protection against the classic "cannot read properties of null". This lesson covers the lib setting, selecting elements precisely, typing events, and creating elements safely.
DOM typings come from the built-in lib.dom.d.ts. They are included automatically when tsconfig.json has no explicit lib array; if you set one, add them yourself:
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"]
}
}dom.iterable lets you loop over NodeList and HTMLCollection with for...of. Node.js-only projects should leave dom out so that browser globals such as window are not silently available on the server.
The return types of the query methods encode two facts: the element might be missing, and its exact type is often unknown.
| Method | Return type |
|---|---|
| getElementById("x") | HTMLElement \| null |
| querySelector("input") | HTMLInputElement \| null (tag name inferred) |
| querySelector(".field") | Element \| null |
| querySelector<HTMLInputElement>(".field") | HTMLInputElement \| null |
| querySelectorAll("li") | NodeListOf<HTMLLIElement> |
When the selector is a bare tag name, TypeScript infers the element type from HTMLElementTagNameMap. For class or id selectors, pass the type argument. Then handle null once, near the top of the module, and the rest of the code stays clean:
const list = document.querySelector<HTMLUListElement>("#todos");
if (!list) throw new Error("#todos not found");
for (const item of list.querySelectorAll("li")) {
item.classList.toggle("done"); // item: HTMLLIElement
}Prefer the generic argument or instanceof over as HTMLInputElement; an assertion is silent when the markup changes, whereas instanceof is a real runtime check:
const el = document.querySelector(".field");
if (el instanceof HTMLInputElement) console.log(el.value);addEventListener is overloaded against HTMLElementEventMap, so the callback parameter is typed from the event name: "click" gives MouseEvent, "input" gives Event, "keydown" gives KeyboardEvent. Two properties need care:
event.target is EventTarget | null, because the event may have bubbled from any descendant. Narrow it with instanceof.event.currentTarget (the element the listener is on) is also EventTarget | null; narrow it or capture the element in a closure.const input = document.querySelector<HTMLInputElement>("#search")!;
input.addEventListener("input", () => {
console.log(input.value); // closure: fully typed, no narrowing needed
});
document.body.addEventListener("click", (event) => {
if (event.target instanceof HTMLButtonElement) {
console.log("button:", event.target.dataset.action);
}
});Custom events carry a typed detail payload through CustomEvent<T>:
const evt = new CustomEvent<{ id: number }>("item:selected", { detail: { id: 7 } });
document.dispatchEvent(evt);
document.addEventListener("item:selected", (e) => {
console.log((e as CustomEvent<{ id: number }>).detail.id);
});Adding "item:selected": CustomEvent<{ id: number }> to DocumentEventMap in a declaration file removes the need for that assertion.
document.createElement is typed by tag name as well, which makes builder functions safe:
function todoItem(text: string, done: boolean): HTMLLIElement {
const li = document.createElement("li"); // HTMLLIElement
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = done;
li.append(checkbox, ` ${text}`);
li.dataset.done = String(done); // dataset values are always strings
return li;
}Properties such as checked, value, disabled and href exist only on the specific element interfaces, which is why keeping the precise type instead of a generic HTMLElement matters. Style properties are typed too: el.style.marginTop = "8px" compiles, el.style.margintop does not.
! after every query. Check once at startup and fail loudly if required markup is missing.event.target.value without narrowing; EventTarget has no value.dom.iterable, which breaks for...of over a NodeList.What is the return type of `document.querySelector("button")`?
lib.dom.d.ts; add dom and dom.iterable to lib when you set it explicitly.Element | null; use tag selectors, a generic argument or instanceof to get the precise element type.addEventListener types the event from its name; event.target is EventTarget | null and needs narrowing.CustomEvent<T> types the detail payload, and event maps can be augmented for custom event names.createElement returns the specific element interface, keeping properties like value and checked type-checked.Next lesson: Using TypeScript with React and Node.js — apply everything to components, props and server code.