You have learned the TypeScript basics, and now you want to build something real in the browser. The moment you do, you bump into the DOM: the live tree of elements that makes up a web page. You grab a button, read an input, listen for a click. In plain JavaScript this is loose and forgiving. In TypeScript it suddenly feels stricter, and you start seeing red squiggles where your code “obviously” works.
That friction is not TypeScript being difficult. It is TypeScript telling you the truth about what the browser actually hands back. Once you understand a handful of element types and one important rule about null, working with the DOM in TypeScript becomes calm and predictable. This article walks you through all of it, slowly, with no assumptions.
A quick reminder: what the DOM is
Before we type anything, let’s anchor the idea. The DOM (Document Object Model) is the browser’s in-memory representation of your HTML. Every tag in your page, from a <div> to an <input>, becomes an object you can read and change with code. When you write document.getElementById("email"), you are reaching into that tree and asking for one specific object.
If this is new to you, it is worth a short detour first. We cover the concept itself in What Is the DOM?, and the practical JavaScript side in The DOM in JavaScript. TypeScript does not replace any of that knowledge. It sits on top of it and adds a safety net.
Every element has a type
In the DOM, not all elements are the same kind of thing. A paragraph and a text input share some behavior, but they also differ. An input has a value property; a paragraph does not. TypeScript captures this with a small family of types.
At the top sits HTMLElement. It is the general “any HTML element” type, and it carries properties that all elements share, like id, className, and style. Below it sit more specific types, one for nearly every kind of tag:
| Type | Matches HTML | Notable extras |
|---|---|---|
HTMLElement |
any element | id, className, style |
HTMLInputElement |
<input> |
value, checked, placeholder |
HTMLButtonElement |
<button> |
disabled, type |
HTMLAnchorElement |
<a> |
href, target |
HTMLFormElement |
<form> |
submit(), elements |
HTMLImageElement |
<img> |
src, alt, width |
The pattern is consistent: HTML + the tag name + Element. A <select> becomes HTMLSelectElement, a <textarea> becomes HTMLTextAreaElement, and so on. You rarely need to memorize these. Your editor suggests them, and the names follow the rule above.
Why does this matter? Because the specific type unlocks the right properties. If TypeScript only knows you have an HTMLElement, it will not let you read .value, because not every element has one. To read an input’s value safely, TypeScript needs to be sure the element really is an input.
Where do these types come from?
You never define HTMLInputElement yourself. These types ship with TypeScript through a built-in library description of the browser. As long as your project targets the DOM, they are simply available everywhere.
Why getElementById can hand you null
Here is the line that surprises almost everyone:
const input = document.getElementById("email");
// input has type: HTMLElement | null
That | null part is the whole story. TypeScript types getElementById as possibly returning null, and it is right to. At the moment your code runs, there may be no element with that id. Maybe the HTML changed, maybe a typo crept in, maybe the script ran before the element existed. The browser would return null, and in plain JavaScript your next line would crash with the classic “cannot read properties of null” error.
TypeScript refuses to let that slip through silently. If you try to use input directly, it stops you:
input.value = "hello@example.com";
// ^ Error: 'input' is possibly 'null'.
This is not TypeScript being annoying. It is catching, at write time, a bug that would otherwise reach your users. The fix is to handle the null case honestly, with a simple check:
const input = document.getElementById("email");
if (input) {
// Inside this block, TypeScript knows input is not null.
input.id; // safe
}
This is called narrowing: once you check if (input), TypeScript narrows the type from HTMLElement | null down to just HTMLElement inside the block. The danger is handled, and the squiggle disappears.
But notice a remaining gap. Even after the check, input is only HTMLElement. TypeScript still will not let you set input.value, because a generic element may not have a value. We need to tell it the element is specifically an input. That brings us to assertions.
Type assertions with as
A type assertion is you telling TypeScript: “I know more than you do here. Trust me, this element is an HTMLInputElement.” You write it with the as keyword:
const input = document.getElementById("email") as HTMLInputElement;
input.value = "jane.doe@example.com"; // now allowed
With that assertion, TypeScript treats input as an input element, and .value becomes available. Clean and short.
But there is a catch you must understand, because it is the most common way people misuse TypeScript. An assertion does not check anything at runtime. It does not convert the element. It only changes what TypeScript believes. If the element with id email is actually a <div>, or does not exist at all, your code will still break when it runs. You have silenced the compiler without making the situation any safer.
`as` is a promise, not a check
When you write as HTMLInputElement, you are taking responsibility. TypeScript stops warning you, but it does nothing to verify your claim. If you are wrong, the error simply moves from compile time to runtime, which is exactly where you did not want it.
So when is as actually okay? Use it when you genuinely know the element’s type and TypeScript cannot infer it. A common, reasonable case is grabbing an input you wrote in your own HTML moments ago. You can see the tag with your own eyes, so the assertion is honest.
A safer habit, though, is to combine a null check with a narrower assertion only where needed:
const el = document.getElementById("email");
if (el instanceof HTMLInputElement) {
// No assertion needed. This is a real runtime check.
el.value = "john.doe@example.com";
}
The instanceof check actually asks the browser, at runtime, “is this really an input?” If it is, TypeScript narrows the type for you, and you never had to make a blind promise. When you can use instanceof, prefer it over as. Reach for as only when a real check is impractical and you are confident about the markup.
A note on querySelector
querySelector behaves a little differently and is often nicer. It also can return null, but it lets you pass the expected type as a generic:
const input = document.querySelector<HTMLInputElement>("#email");
// type: HTMLInputElement | null
if (input) {
input.value = "support@acy-partner.com";
}
Here you still handle the null, but you avoid a raw as cast. You told querySelector what you expect, and you still checked that the element was found. This pattern reads well and keeps you safe.
Typing event handlers
The other place TypeScript shines in the DOM is events. When a user clicks, types, or submits a form, the browser hands your callback an event object. TypeScript knows the exact shape of that object, and which kind of event it is.
Start with a click:
const button = document.querySelector<HTMLButtonElement>("#save");
button?.addEventListener("click", (event: MouseEvent) => {
console.log("clicked at", event.clientX, event.clientY);
});
A click produces a MouseEvent, so event carries mouse-specific data like clientX. Notice the ?. after button: that optional chaining means “only call addEventListener if button is not null,” neatly handling the null case in one character.
Reading what a user typed is a classic task, and it has one well-known wrinkle. The event.target is the element that fired the event, but TypeScript types it broadly, because in theory any element could be the target. To read .value, you narrow it:
const field = document.querySelector<HTMLInputElement>("#search");
field?.addEventListener("input", (event: Event) => {
const target = event.target as HTMLInputElement;
console.log("user typed:", target.value);
});
Here as HTMLInputElement is reasonable, because you attached the listener to an input and you know the target is that input. Still, if you prefer the safer route, if (event.target instanceof HTMLInputElement) works just as well and needs no assertion.
For forms, the submit event has its own type, and stopping the default page reload is the usual first step:
const form = document.querySelector<HTMLFormElement>("#signup");
form?.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
// read fields, validate, send to your API...
});
Let inference do the work
Inside addEventListener("click", ...), TypeScript already knows the event is a MouseEvent based on the event name. You often do not need to write the type annotation at all. Type it explicitly only when you are defining the handler separately from where you attach it.
Putting it together
Here is a small, complete example that ties every idea above into something you might actually write: a search box that reacts as the user types.
const form = document.querySelector<HTMLFormElement>("#search-form");
const input = document.querySelector<HTMLInputElement>("#query");
const output = document.querySelector<HTMLElement>("#result");
if (form && input && output) {
input.addEventListener("input", () => {
output.textContent = `Searching for: ${input.value}`;
});
form.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
output.textContent = `You searched for: ${input.value}`;
});
}
Read what made this safe. Each element was fetched with a typed querySelector, so the types are precise. A single if (form && input && output) check cleared all three null possibilities at once, and inside that block every element is the exact type we need. We never had to write a single as cast, because the generics and the null check did the work. This is the calm, predictable style you are aiming for.
Recap
Working with the DOM in TypeScript comes down to a few steady ideas. Every element has a type, from the general HTMLElement down to specific ones like HTMLInputElement that unlock the right properties. Lookups such as getElementById can return null, and TypeScript forces you to handle that, which prevents a whole class of runtime crashes. Type assertions with as are a promise you make to the compiler, not a real check, so use them only when you truly know the markup, and prefer instanceof or a typed querySelector with a null check whenever you can. Finally, event handlers come with precise event types like MouseEvent and SubmitEvent, and inference often types them for you.
None of this is busywork. Each rule maps to a real bug it stops at write time instead of in front of a user. Once the patterns feel natural, you stop fighting the red squiggles and start letting them guide you toward code that simply does not break.