If you've ever written `event: any` in a handler just to make the compiler shut up, you don't need to feel bad about it. Pretty much everyone who works with TypeScript and the DOM has done it at least once. The problem is that this quick fix tends to come back and bite you later, usually on a Friday afternoon, when someone clicks a button and `event.target.value` simply doesn't exist because TypeScript never knew that target was an input in the first place. Let's take a pretty common example. You have a click listener and want to read the value of a text field: ```typescript document.querySelector('input')?.addEventListener('click', (event) => { console.log(event.target.value); // error: Property 'value' does not exist on type 'EventTarget' }); ``` TypeScript complains because `event.target` is typed as `EventTarget`, a fairly generic interface that knows nothing about inputs, buttons, or any specific element. It makes sense once you think about it: the target of an event could be pretty much anything in the DOM, so the compiler has no way of guessing that it's an `<input>`. Still, this catches a lot of people off guard, because in plain JavaScript it just worked without complaining at all. The good news is that TypeScript already ships with a fairly complete hierarchy of event types, and that's the foundation for everything else. `Event` is the root. From there come `UIEvent`, which in turn gives rise to `MouseEvent`, `KeyboardEvent`, `FocusEvent`, `InputEvent`, and others. Each one carries context-specific properties: `MouseEvent` has `clientX` and `clientY`, `KeyboardEvent` has `key` and `code`, and so on. Using the right type instead of a generic `Event` already solves a good chunk of the problems, because autocomplete starts showing you exactly what's available on that event. ```typescript button.addEventListener('click', (event: MouseEvent) => { console.log(event.clientX, event.clientY); // works without complaints }); ``` Worth a quick aside here for anyone coming from React: the `ChangeEvent` you import from `'react'` isn't a native DOM type, it's React's own synthetic type, which wraps the real event in its own layer (SyntheticEvent). This trips up a lot of people migrating from plain projects into JSX, because the names look the same but the origins are completely different. In pure DOM code you won't find `ChangeEvent` anywhere — the native equivalent is usually handling the `input` or `change` event as `Event` itself, and casting the target manually. Speaking of `target`, that's probably the point that generates the most silent bugs day to day. `event.target` is the element that actually fired the event, and `event.currentTarget` is the element the listener was attached to. In a tree using event delegation, this makes a real difference: if you have a listener on a `<ul>` catching clicks that bubble up from the `<li>` elements, `target` will be the clicked `<li>`, but `currentTarget` stays the `<ul>`. TypeScript usually infers a more specific type for `currentTarget` right inside the handler, since it knows exactly which element the listener was registered on. `target`, on the other hand, stays generic as `EventTarget`, because there's no way to know in advance who's going to trigger the click. ```typescript list.addEventListener('click', (event) => { console.log(event.currentTarget); // TypeScript already knows it's the <ul> console.log(event.target); // still EventTarget, needs narrowing }); ``` One interesting thing is that when you use `addEventListener` with a string literal TypeScript recognizes, like `'click'`, `'keydown'`, or `'submit'`, the compiler automatically infers the right event type for you, no annotation needed. That's thanks to an overload that exists internally in `HTMLElement`'s type definition. The problem shows up when you extract that handler into a separate function, or write a generic wrapper to register several listeners at once. In those cases the inference gets lost and the parameter goes back to being plain `Event`, so you need to annotate it manually again. To solve the problem of an untyped `target`, there are basically two paths, and it's worth knowing when to use each. The fastest one is a type assertion: ```typescript input.addEventListener('input', (event) => { const target = event.target as HTMLInputElement; console.log(target.value); }); ``` It works, but it's a promise you're making to the compiler with no proof behind it. If for some reason the element isn't actually an `HTMLInputElement`, TypeScript won't warn you anywhere, and the error will only show up at runtime. The safer path is writing a type guard function, which actually checks before asserting the type: ```typescript function isInputElement(el: EventTarget | null): el is HTMLInputElement { return el instanceof HTMLInputElement; } input.addEventListener('input', (event) => { if (isInputElement(event.target)) { console.log(event.target.value); // genuinely safe in here } }); ``` It's a bit more code, sure, but in larger codebases, where the same handler might end up reused in different contexts, this check avoids a pretty annoying kind of bug to track down, since the runtime error usually shows up far from where the wrong assertion was originally written. If the project uses React, Vue, or Svelte, each framework has its own layer of event typing, and it's worth not mixing the concepts up. In React, the pattern is to use the generic types with the element as a parameter, like `React.MouseEvent<HTMLButtonElement>` or `React.ChangeEvent<HTMLInputElement>`. In Vue with TypeScript, you generally type things directly in the template or use native DOM types inside `<script setup>`, since Vue doesn't wrap the event in a synthetic layer the way React does. Svelte tends to stay closer to plain DOM too. There's no room to go deep into all of them here without turning this into a book, but it helps just to know these differences exist before copying an example from Stack Overflow that was written with a different framework in mind. Some mistakes come up over and over, and they're worth naming just to reinforce what to avoid. Using `any` on the event parameter fixes the error right away but throws out all the type safety TypeScript offers. Forcing a cast, like `as HTMLInputElement`, without ever validating that it's actually true, is basically bringing back the plain JavaScript problem, just hidden behind an annotation. And ignoring the generic overloads of `addEventListener`, writing manual types that already exist in TypeScript's standard lib, tends to create inconsistency whenever the lib definition changes in an update. To wrap up with something more complete, picture a simple signup form, with a text field and a submit button, where you want to validate the value before submitting: ```typescript const form = document.querySelector('form') as HTMLFormElement; const input = form.querySelector('input[name="email"]') as HTMLInputElement; form.addEventListener('submit', (event: SubmitEvent) => { event.preventDefault(); if (isInputElement(input) && input.value.includes('@')) { console.log('valid email:', input.value); } else { console.log('fix that email before sending'); } }); function isInputElement(el: EventTarget | null): el is HTMLInputElement { return el instanceof HTMLInputElement; } ``` Notice how this pulls together pretty much everything we covered: the specific `SubmitEvent` type instead of a generic `Event`, a controlled cast on `querySelector`, and the narrowing function to validate `target` before touching it. In my experience, that's roughly how typing DOM events ends up playing out in practice — not through one single rule, but through this set of small decisions repeated in every handler you write.

