Eventos del DOM con TypeScript: querySelector, addEventListener y eventos propios

En el navegador, TypeScript conoce todos los elementos y eventos del DOM. Sabe que keydown trae la tecla pulsada y que un input tiene value. Pero también es estricto: un elemento que buscas puede no existir, y e.target puede ser cualquier cosa. Lo vemos con un carrito de compra pequeño.

La página

Una lista con tres artículos y un botón para quitar cada uno, un campo para el cupón de descuento y un párrafo para el total. El navegador no ejecuta TypeScript: carga carrito.js, que genera tsc a partir de carrito.ts.

<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Carrito</title></head>
<body>
  <ul id="lista">
    <li data-precio="1299.99">Audífonos <button class="quitar">Quitar</button></li>
    <li data-precio="899">Webcam <button class="quitar">Quitar</button></li>
    <li data-precio="189">Cable HDMI <button class="quitar">Quitar</button></li>
  </ul>
  <label>Cupón <input id="cupon" type="text"></label>
  <p id="total"></p>
  <script type="module" src="carrito.js"></script>
</body>
</html>

El código

// Un evento propio, con el tipo de sus datos. Así TypeScript lo conoce como a "click".
declare global {
  interface HTMLElementEventMap {
    "carrito:cambio": CustomEvent<{ total: number; articulos: number }>;
  }
}

// querySelector con una etiqueta ya sabe el tipo: HTMLUListElement | null.
const lista = document.querySelector("ul");
// Con un id no lo sabe; se lo decimos con el genérico.
const cupon = document.querySelector<HTMLInputElement>("#cupon");
const salida = document.getElementById("total");
if (!lista || !cupon || !salida) throw new Error("Falta algún elemento en la página");

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
let descuento = 0;

// Una función flecha en una const: conserva la comprobación de arriba (lista no es null).
const recalcular = (): void => {
  const precios = [...lista.querySelectorAll("li")].map((li) => Number(li.dataset.precio));
  const total = precios.reduce((s, p) => s + p, 0) * (1 - descuento);
  lista.dispatchEvent(new CustomEvent("carrito:cambio", { detail: { total, articulos: precios.length } }));
};

// Un solo escuchador en la lista para todos los botones (delegación).
lista.addEventListener("click", (e) => {
  // e es PointerEvent; e.target es EventTarget | null: hay que comprobar qué es.
  if (!(e.target instanceof HTMLButtonElement) || !e.target.matches(".quitar")) return;
  const li = e.target.closest("li");
  console.log(`quitar: ${li?.firstChild?.textContent?.trim()} (botón ${e.button})`);
  li?.remove();
  recalcular();
});

// "keydown" da un KeyboardEvent: e.key existe y es string.
cupon.addEventListener("keydown", (e) => {
  if (e.key !== "Enter") return;
  descuento = cupon.value.trim().toUpperCase() === "DECODIGO10" ? 0.1 : 0;
  console.log(`cupón «${cupon.value}»: descuento ${descuento * 100} %`);
  recalcular();
});

// detail ya tiene tipo: { total: number; articulos: number }.
lista.addEventListener("carrito:cambio", (e) => {
  const { articulos, total } = e.detail;
  salida.textContent = `${articulos} ${articulos === 1 ? "artículo" : "artículos"}, total ${usd.format(total)}`;
  console.log(salida.textContent);
});

recalcular();
export {};

Se compila con la biblioteca de tipos del DOM (--lib dom), que en un proyecto de navegador ya suele venir en el tsconfig.json:

npx tsc --strict --target es2022 --module esnext --lib es2022,dom --locale es carrito.ts
tsc(sin errores: genera carrito.js)

Abrimos la página en Chromium, escribimos el cupón, pulsamos Enter, quitamos dos artículos y hacemos clic en el texto de uno, que no es un botón. Esto es lo que sale en la consola:

consola del navegador3 artículos, total $2,387.99
--- escribir DECODIGO10 y pulsar Enter
cupón «decodigo10»: descuento 10 %
3 artículos, total $2,149.19
--- quitar la webcam
quitar: Webcam (botón 0)
2 artículos, total $1,340.09
--- clic en el texto de un artículo (no es un botón)
--- quitar los audífonos
quitar: Audífonos (botón 0)
1 artículo, total $170.10
--- texto final: 1 artículo, total $170.10
errores de JavaScript: 0

El clic en el texto no hace nada, como debe: la delegación comprueba que e.target sea un botón .quitar antes de actuar.

Cada nombre de evento corresponde a un tipo de evento con sus propiedadesEl nombre del evento decide el tipo de eaddEventListener(tipo de elo que puedes usar"click"PointerEvente.button, e.clientX"keydown"KeyboardEvente.key, e.ctrlKey"input"Eventsolo lo común a todos"carrito:cambio"CustomEvent<{…}>e.detail.totalLa tabla sale de HTMLElementEventMap. Los eventos propios se añaden aesa misma interfaz con declare global, y desde entonces tienen tipo.

El nombre del evento que pasas a addEventListener decide el tipo de e. Esquema: decodigo.com.

Las cuatro cosas que TypeScript te obliga a pensar

Este archivo junta los errores típicos del DOM. Cada uno corresponde a algo que en JavaScript solo descubres cuando la página falla:

const cupon = document.querySelector("#cupon");
console.log(cupon.value);

const entrada = document.querySelector<HTMLInputElement>("#cupon")!;
entrada.addEventListener("click", (e) => {
  console.log(e.key);
});
entrada.addEventListener("input", (e) => {
  console.log(e.target.value);
});
entrada.addEventListener("cambio-inventado", (e) => {
  console.log(e.detail);
});
npx tsc --strict --target es2022 --module esnext --lib es2022,dom --locale es --noEmit errores.ts
tscerrores.ts(2,13): error TS18047: "cupon" es posiblemente "null".
errores.ts(2,19): error TS2339: La propiedad 'value' no existe en el tipo 'Element'.
errores.ts(6,17): error TS2339: La propiedad 'key' no existe en el tipo 'PointerEvent'.
errores.ts(9,15): error TS18047: "e.target" es posiblemente "null".
errores.ts(9,24): error TS2339: La propiedad 'value' no existe en el tipo 'EventTarget'.
errores.ts(12,17): error TS2339: La propiedad 'detail' no existe en el tipo 'Event'.
ErrorPor quéLa solución
"cupon" es posiblemente "null"Si nadie escribió ese id en el HTML, querySelector devuelve null.Comprobarlo una vez (if (!cupon) throw …). A partir de ahí TypeScript sabe que existe.
'value' no existe en el tipo 'Element'Con un selector de id o de clase, TypeScript no sabe qué etiqueta es.querySelector<HTMLInputElement>("#cupon"). Con una etiqueta, como querySelector("ul"), ya lo sabe solo.
'key' no existe en el tipo 'PointerEvent'Un clic no trae tecla: el evento "click" es un PointerEvent.Escuchar "keydown", que da un KeyboardEvent.
'value' no existe en el tipo 'EventTarget'e.target es lo que se pulsó, que puede ser un hijo del elemento, y TypeScript no sabe cuál.Comprobarlo con instanceof, o usar el elemento que ya tienes.
'detail' no existe en el tipo 'Event'TypeScript no conoce el evento "cambio-inventado".Declararlo en HTMLElementEventMap, como "carrito:cambio" en el ejemplo.

Una trampa: las funciones declaradas olvidan la comprobación

En el ejemplo, recalcular es una función flecha guardada en una const. No es por estilo. Con una declaración function, TypeScript no puede dar por hecha la comprobación de arriba, porque esas funciones se «elevan» al principio del archivo y podrían llamarse antes:

const lista = document.querySelector("ul");
if (!lista) throw new Error("Falta la lista");

// Una declaración function se «eleva» al principio del archivo: TypeScript no puede
// suponer que la comprobación de arriba ya se hizo cuando alguien la llame.
function contar(): number {
  return lista.children.length;
}
console.log(contar());
npx tsc --strict --target es2022 --module esnext --lib es2022,dom --locale es --noEmit declaracion.ts
tscdeclaracion.ts(7,10): error TS18047: "lista" es posiblemente "null".
Eventos propios con tipo. declare global amplía la interfaz HTMLElementEventMap que trae TypeScript. Desde ese momento, addEventListener("carrito:cambio", …) recibe un CustomEvent cuyo detail tiene la forma declarada, y dispatchEvent se escribe igual que siempre. El export {} del final hace que el archivo sea un módulo, que es donde declare global está permitido.

Todo el código de esta página se comprobó con TypeScript 7.0.2 en modo estricto (--strict --locale es) en un contenedor limpio, y la página se probó en Chromium (Playwright 1.49.1); los mensajes y la salida de la consola están copiados de esa ejecución. Documentación oficial: addEventListener en MDN.