The short answer
Narrow the element first, then use that variable inside the listener. The event’s own type comes from the event name:
const form = document.querySelector("form");
const input = document.querySelector("input[type=search]");
if (form && input instanceof HTMLInputElement) {
input.addEventListener("input", () => {
form.dataset.empty = String(input.value.trim() === "");
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
console.log(data.get("q"));
});
document.addEventListener("keydown", (event) => {
if (event.key === "/" && !event.ctrlKey) input.focus();
});
}
/** @param {PointerEvent} event */
function onCartClick(event) {
if (event.target instanceof HTMLButtonElement) event.target.disabled = true;
}
document.getElementById("cart")?.addEventListener("click", onCartClick);const form = document.querySelector("form");
const input = document.querySelector("input[type=search]");
if (form && input instanceof HTMLInputElement) {
input.addEventListener("input", () => {
form.dataset.empty = String(input.value.trim() === "");
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
console.log(data.get("q"));
});
document.addEventListener("keydown", (event) => {
if (event.key === "/" && !event.ctrlKey) input.focus();
});
}
function onCartClick(event: PointerEvent): void {
if (event.target instanceof HTMLButtonElement) event.target.disabled = true;
}
document.getElementById("cart")?.addEventListener("click", onCartClick);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} "submit" gives a SubmitEvent, "keydown" a KeyboardEvent (so event.key exists), and a named handler can declare its event type with @param. When the element comes from event.target, an instanceof check narrows it.
Why it works
TypeScript’s DOM library has an event map per object (HTMLElementEventMap, DocumentEventMap, WindowEventMap). addEventListener("keydown", …) looks the name up in the map and types the listener’s event parameter, so inline arrow functions need no JSDoc. The element the listener belongs to is already narrowed in the enclosing code, and the closure keeps that type.
The same thing in TypeScript
The TypeScript equivalent tab differs only in onCartClick(event: PointerEvent). The listeners themselves are inferred the same way in both languages.
What goes wrong
event.target is EventTarget | null, so reading .value from it fails twice (TS18047, TS2339); event.currentTarget has the same type, which FormData refuses (TS2345); and a resize event is a UIEvent, which has no key:
const input = document.querySelector("input");
input?.addEventListener("input", (event) => {
console.log(event.target.value);error TS18047: 'event.target' is possibly 'null'.error TS2339: Property 'value' does not exist on type 'EventTarget'.
});
document.querySelector("form")?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);error TS2345: Argument of type 'EventTarget | null' is not assignable to parameter of type 'HTMLFormElement | undefined'.
Type 'null' is not assignable to type 'HTMLFormElement | undefined'.
console.log(data);
});
window.addEventListener("resize", (event) => {
console.log(event.key);error TS2339: Property 'key' does not exist on type 'UIEvent'.
});jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} Gotchas
clickis aPointerEventin TypeScript 5.9’s DOM types. A handler declared with@param {MouseEvent} eventis still accepted, since aPointerEventis aMouseEvent.- Listeners on a plain
Elementlose the map.document.querySelector(".cart")returnsElement, whose map lacks"click", so a handler with a typed event parameter is refused; select by id or tag, or narrow withinstanceoffirst. thisin afunctionlistener is the element. Afunction(not an arrow) listener getsthistyped as the element; see typing this.
Questions
Why is event.target not the element I added the listener to?
Events bubble: target is where the event started, which can be any descendant (or a text node's parent). currentTarget is the element with the listener, but TypeScript's DOM types still declare it as EventTarget | null, because the same event object travels through many elements.
How do I type a custom event's detail?
Create it as new CustomEvent("cart:add", { detail: { sku } }) and give listeners the type with @param {CustomEvent<{ sku: string }>} event. For addEventListener to infer it from the name, the event map must be extended in a .d.ts file, like the tag name map in the custom elements recipe.
Sources
- EventTarget: addEventListener() method · MDN Web Docs
- Event: currentTarget property · MDN Web Docs
Last reviewed 21 October 2026.