Saltar al contenido
dom.ts · devschool

TypeScript y el DOM

Lección 14 de 15 · 17 min de lectura · Actualizado el

En esta lección
  1. Los tipos del DOM vienen incluidos
  2. Seleccionar elementos
  3. Tratar el null: cuatro formas
  4. Eventos tipados
  5. Formularios
  6. fetch con tipos
  7. Crear elementos
  8. Errores frecuentes
  9. Resumen

Hasta ahora has usado TypeScript con datos que tú mismo creabas. En el navegador la cosa cambia: los elementos vienen del HTML, los eventos los lanza el usuario y los datos llegan de un servidor. TypeScript no puede ver tu HTML, así que te obliga a comprobar lo que JavaScript daba por hecho. En esta lección verás los tipos del DOM (el árbol de elementos de la página), cómo seleccionar elementos sin pelearte con null, cómo tipar eventos y formularios y cómo dar tipo a lo que devuelve fetch.

Si no recuerdas cómo funciona el DOM en JavaScript, repasa antes El DOM, Eventos y fetch: aquí nos centramos en lo que añade TypeScript.

Los tipos del DOM vienen incluidos

TypeScript trae un archivo de declaraciones con los tipos de todo lo que ofrece el navegador: document, window, localStorage, fetch… Se activa con la opción lib del tsconfig.json:

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022", "dom", "dom.iterable"],
    "strict": true
  }
}
  • es2022: los tipos del lenguaje (Array, Map, Promise…).
  • dom: los tipos del navegador.
  • dom.iterable: permite recorrer listas de elementos con for...of. En TypeScript 6 ya va dentro de dom, pero ponerlo no hace daño y sirve también para TypeScript 5.

Si no escribes lib, TypeScript incluye el DOM por defecto. Al revés: en un proyecto de Node.js conviene quitarlo, para que no te deje usar document donde no existe. Lo verás con más detalle en Módulos y tsconfig.

Una familia de tipos

Cada elemento HTML tiene su propio tipo, y todos forman una jerarquía, como las clases con herencia:

EventTarget            cualquier cosa que recibe eventos
└── Node               cualquier nodo del árbol (también los textos)
    └── Element        cualquier elemento (también los de SVG)
        └── HTMLElement          cualquier elemento HTML
            ├── HTMLInputElement     <input>
            ├── HTMLButtonElement    <button>
            ├── HTMLFormElement      <form>
            ├── HTMLSelectElement    <select>
            ├── HTMLAnchorElement    <a>
            ├── HTMLImageElement     <img>
            └── ... (uno por etiqueta)

Cuanto más abajo, más propiedades tienes. Un HTMLElement tiene textContent, classList o hidden, pero no tiene value: eso solo lo tienen los campos de formulario como HTMLInputElement. Esta es la causa de la mayoría de errores que verás al empezar.

TipoPropiedades típicas
HTMLElementtextContent, classList, dataset, hidden, style
HTMLInputElementvalue, valueAsNumber, checked, type, disabled
HTMLSelectElementvalue, selectedIndex, options
HTMLFormElementreset(), elements, checkValidity()
HTMLAnchorElementhref, target
HTMLImageElementsrc, alt, width, height

Seleccionar elementos

Los ejemplos de esta lección usan este HTML de una página de reservas de un club de montaña:

<form id="reserva">
  <input id="nombre" name="nombre" required>
  <input id="plazas" name="plazas" type="number" min="1" value="1">
  <select id="ruta" name="ruta">
    <option value="cares">Ruta del Cares</option>
    <option value="oso">Senda del Oso</option>
  </select>
  <label><input id="seguro" name="seguro" type="checkbox"> Seguro</label>
  <button>Reservar</button>
</form>
<p id="mensaje"></p>
<ul id="rutas"></ul>

getElementById

getElementById devuelve HTMLElement | null: un elemento HTML cualquiera o nada, si no existe ese id.

const mensaje = document.getElementById("mensaje");
mensaje.textContent = "Hola";
// error TS18047: 'mensaje' is possibly 'null'.

Con strict activado, TypeScript no te deja usarlo sin comprobar antes que existe. No es una manía: si alguien cambia el id en el HTML, en JavaScript tendrías un TypeError en el navegador; en TypeScript te enteras al compilar que ese caso no está cubierto.

querySelector con una etiqueta

Si el selector es el nombre de una etiqueta, TypeScript sabe exactamente qué tipo devuelve:

const formulario = document.querySelector("form"); // HTMLFormElement | null
const lista = document.querySelector("ul");        // HTMLUListElement | null
const boton = document.querySelector("button");    // HTMLButtonElement | null

querySelector con un selector CSS

Con cualquier otro selector (#id, .clase, form input) TypeScript no puede adivinar qué encontrará y devuelve el tipo más general, Element | null:

const nombre = document.querySelector("#nombre");
if (nombre) {
  console.log(nombre.value);
  // error TS2339: Property 'value' does not exist on type 'Element'.
}

Tú sabes que #nombre es un <input>, pero TypeScript no lo ve. Para decírselo, querySelector es una función genérica y acepta el tipo entre < >:

const nombre = document.querySelector<HTMLInputElement>("#nombre");
// HTMLInputElement | null

if (nombre) {
  console.log(nombre.value);
}

Esto es equivalente a una aserción con as, pero conserva el | null, así que sigues obligado a comprobar que existe. Es la forma más habitual de seleccionar elementos.

Cuidado: <HTMLInputElement> es una promesa tuya, no una comprobación. Si #nombre resulta ser un <div>, TypeScript te cree igualmente y nombre.value valdrá undefined al ejecutar.

Varios elementos

querySelectorAll funciona igual, pero devuelve una NodeListOf<...>, que nunca es null (como mucho, está vacía):

const campos = document.querySelectorAll<HTMLInputElement>("#reserva input");

for (const campo of campos) {
  campo.disabled = true;
}

const nombres = Array.from(campos, (campo) => campo.name);
console.log(nombres.join(", ")); // nombre, plazas, seguro

Tratar el null: cuatro formas

Casi todo lo que sacas del DOM puede ser null. Tienes varias maneras de resolverlo, y cada una tiene su momento.

1. Comprobar con if

Lo más seguro. Dentro del if TypeScript sabe que el valor existe (es el estrechamiento de tipos):

const mensaje = document.getElementById("mensaje");

if (mensaje) {
  mensaje.textContent = "Reserva guardada";
}

Úsalo cuando el elemento puede no estar, por ejemplo en un script compartido por varias páginas.

2. Encadenamiento opcional

Para leer algo y aceptar undefined si no existe, ?. es más corto:

const titulo = document.querySelector("h1")?.textContent ?? "Sin título";

No sirve para asignar: elemento?.textContent = "x" es un error de sintaxis.

3. Lanzar un error

Si el elemento tiene que existir (sin él la página no funciona), lo más claro es fallar pronto y con un mensaje útil:

const lista = document.querySelector<HTMLUListElement>("#rutas");
if (!lista) {
  throw new Error("Falta el elemento #rutas en el HTML");
}

// A partir de aquí, lista es HTMLUListElement (sin null)
lista.innerHTML = "";

4. El operador ! (non-null assertion)

El signo ! detrás de una expresión le dice a TypeScript: “esto no es null ni undefined, te lo aseguro”. Quita el | null del tipo:

const formulario = document.querySelector("form")!; // HTMLFormElement
formulario.reset();

Es cómodo, pero igual que as no comprueba nada: si el elemento no existe, el error llega al ejecutar (Cannot read properties of null). Úsalo solo cuando estés seguro de que el elemento existe: tu propio HTML, con el script cargado con type="module" o defer (que esperan a que el HTML esté leído).

Nota: no confundas este ! con el ! de negación. !valor (delante) significa “no”; valor! (detrás) significa “no es null”.

Una función de ayuda segura

En proyectos medianos es habitual escribir una pequeña función que selecciona, comprueba el tipo de verdad con instanceof y lanza un error claro si algo no cuadra:

function elemento<T extends Element>(selector: string, tipo: { new (): T }): T {
  const el = document.querySelector(selector);
  if (!(el instanceof tipo)) {
    throw new Error(`No encuentro ${selector} o no es del tipo esperado`);
  }
  return el;
}

const plazas = elemento("#plazas", HTMLInputElement); // HTMLInputElement
const ruta = elemento("#ruta", HTMLSelectElement);    // HTMLSelectElement

console.log(plazas.valueAsNumber, ruta.value); // 1 cares

El segundo parámetro es la propia clase del elemento (HTMLInputElement, sin comillas). { new (): T } significa “algo que se puede crear con new y da un T”, y gracias a eso instanceof tipo estrecha el hasta T. Ya no hay null ni promesas: si devuelve algo, es del tipo correcto.

FormaComprueba al ejecutarCuándo usarla
if (el) { ... }SíEl elemento puede faltar
el?.propSíSolo leer, con un valor por defecto
if (!el) throw ...SíEl elemento es imprescindible
querySelector(...)!NoEstás seguro y quieres código corto
querySelector<T>(...)No (el tipo)Selector CSS cuyo tipo conoces
instanceofSí (existencia y tipo)Código que debe ser robusto

Eventos tipados

Cuando escribes la función del evento directamente dentro de addEventListener, TypeScript infiere el tipo del evento a partir de su nombre:

const boton = document.querySelector("button")!;

boton.addEventListener("click", (evento) => {
  // evento: MouseEvent (PointerEvent en versiones recientes)
  console.log(evento.clientX, evento.clientY);
});

document.addEventListener("keydown", (evento) => {
  // evento: KeyboardEvent
  if (evento.key === "Escape") {
    console.log("Cancelado");
  }
});
EventoTipo
click, dblclickMouseEvent (o PointerEvent, que lo amplía)
keydown, keyupKeyboardEvent
submitSubmitEvent
inputInputEvent o Event, según la versión
change, loadEvent
focus, blurFocusEvent

Si escribes una equivocación, como evento.tecla en un keydown, TypeScript avisa al momento. Y si escribes mal el nombre del evento ("clik"), el evento pasa a ser un Event genérico y no tendrás clientX: otra pista de que algo va mal.

Funciones con nombre

Si separas la función, la inferencia desaparece y tienes que anotar el parámetro tú:

function alPulsarTecla(evento: KeyboardEvent): void {
  console.log(`Has pulsado ${evento.key}`);
}

document.addEventListener("keydown", alPulsarTecla);

Si te equivocas de tipo (por ejemplo, usar esa función en un click), TypeScript lo detecta, porque un MouseEvent no tiene key.

target y currentTarget

Aquí está la trampa más común. evento.target es de tipo EventTarget | null, no el elemento concreto:

const lista = document.querySelector("ul")!;

lista.addEventListener("click", (evento) => {
  console.log(evento.target.textContent);
  // error TS18047: 'evento.target' is possibly 'null'.
  // error TS2339: Property 'textContent' does not exist on type 'EventTarget'.
});

¿Por qué tan general? Porque el target es el elemento donde se hizo clic, que puede ser el <ul>, cualquier <li> o algo dentro de un <li>. TypeScript no puede saberlo. La solución correcta es comprobarlo con instanceof, que es justo lo que necesitas en la delegación de eventos (un solo evento en el padre para todos los hijos):

const lista = document.querySelector("ul")!;

lista.addEventListener("click", (evento) => {
  if (!(evento.target instanceof HTMLElement)) return;

  const item = evento.target.closest("li");
  if (item) {
    item.classList.toggle("elegida");
    console.log(item.dataset.id);
  }
});

Tras el instanceof, evento.target es HTMLElement y ya puedes usar closest. Y como el argumento de closest es una etiqueta, item es HTMLLIElement | null.

evento.currentTarget (el elemento que tiene el evento, aquí el <ul>) también es EventTarget | null. Si necesitas ese elemento, lo más sencillo es usar directamente la variable (lista), que ya tiene su tipo.

Formularios

Los formularios juntan todo lo anterior. Recuerda que el value de un campo siempre es un string, aunque sea type="number". TypeScript lo deja claro: plazas.value es string, así que no puedes sumarlo sin convertirlo.

const form = document.querySelector<HTMLFormElement>("#reserva")!;
const nombre = document.querySelector<HTMLInputElement>("#nombre")!;
const plazas = document.querySelector<HTMLInputElement>("#plazas")!;
const seguro = document.querySelector<HTMLInputElement>("#seguro")!;

form.addEventListener("submit", (evento) => {
  evento.preventDefault();

  const total: number = plazas.valueAsNumber * 15 + (seguro.checked ? 3 : 0);
  console.log(`${nombre.value.trim()}: ${total} €`);
});
  • valueAsNumber devuelve el campo como number (o NaN si está vacío). Es más cómodo que Number(plazas.value).
  • checked es boolean.
  • evento es SubmitEvent; preventDefault() evita que la página se recargue.

Con FormData y una interfaz

Cuando hay muchos campos, en vez de seleccionarlos uno a uno, se lee el formulario entero con FormData y se convierte en un objeto con una interfaz:

type RutaId = "cares" | "oso";

interface Reserva {
  nombre: string;
  plazas: number;
  ruta: RutaId;
  seguro: boolean;
}

function leerReserva(form: HTMLFormElement): Reserva {
  const datos = new FormData(form);

  const ruta = String(datos.get("ruta"));
  if (ruta !== "cares" && ruta !== "oso") {
    throw new Error(`Ruta desconocida: ${ruta}`);
  }

  return {
    nombre: String(datos.get("nombre") ?? "").trim(),
    plazas: Number(datos.get("plazas")),
    ruta,
    seguro: datos.has("seguro"),
  };
}

const form = document.querySelector<HTMLFormElement>("#reserva")!;

form.addEventListener("submit", (evento) => {
  evento.preventDefault();
  const reserva = leerReserva(form);
  console.log(reserva);
  // {nombre: 'Lucía', plazas: 2, ruta: 'oso', seguro: true}
});

Fíjate en los detalles que TypeScript te obliga a pensar:

  • datos.get("nombre") devuelve FormDataEntryValue | null, es decir, string, File (si es un campo de archivo) o null (si el campo no existe). Por eso se convierte con String(...).
  • Un checkbox sin marcar no aparece en el FormData. has("seguro") da el boolean que buscas.
  • ruta llega como string, y la interfaz pide "cares" | "oso". La comprobación con if estrecha el tipo y, de paso, te protege de alguien que manipule el HTML.

La función leerReserva es el único lugar donde los datos “sucios” del formulario se convierten en datos con tipo. El resto del programa trabaja ya con Reserva y no tiene que preocuparse de nada.

fetch con tipos

fetch está tipado: devuelve Promise<Response>. El problema está en respuesta.json(), que devuelve Promise<any>. TypeScript no puede saber qué JSON va a enviar el servidor, así que te da any, y con él se apagan las comprobaciones.

const respuesta = await fetch("/api/rutas.json");
const rutas = await respuesta.json(); // any

console.log(rutas.longitud.toFixed(1));
// Compila sin errores... y falla al ejecutar si no existe "longitud"

Anotar el resultado

Supón que el servidor devuelve este JSON:

[
  { "id": 1, "nombre": "Ruta del Cares", "km": 12, "dificultad": "media" },
  { "id": 2, "nombre": "Senda del Oso", "km": 22, "dificultad": "baja" }
]

Lo mínimo es describir los datos con una interfaz y escribir el tipo de retorno de la función que hace la petición:

interface Ruta {
  id: number;
  nombre: string;
  km: number;
  dificultad: "baja" | "media" | "alta";
}

async function cargarRutas(): Promise<Ruta[]> {
  const respuesta = await fetch("/api/rutas.json");
  if (!respuesta.ok) {
    throw new Error(`Error HTTP ${respuesta.status}`);
  }
  return respuesta.json();
}

const rutas = await cargarRutas(); // Ruta[]
const largas = rutas.filter((r) => r.km > 15).map((r) => r.nombre);
console.log(largas.join(", ")); // Senda del Oso

Ahora rutas es Ruta[] y el editor te autocompleta nombre, km… y te avisa si escribes r.longitud.

Una función genérica para todas las peticiones

Como todas las peticiones se parecen, se puede escribir una sola función con un genérico:

async function obtenerJSON<T>(url: string): Promise<T> {
  const respuesta = await fetch(url);
  if (!respuesta.ok) {
    throw new Error(`Error HTTP ${respuesta.status} en ${url}`);
  }
  return (await respuesta.json()) as T;
}

const rutas = await obtenerJSON<Ruta[]>("/api/rutas.json");
const socio = await obtenerJSON<{ nombre: string; cuota: number }>("/api/socios/7.json");

Cuidado: tanto el tipo de retorno como el as T son promesas tuyas. TypeScript no mira el JSON que llega: si el servidor manda otra cosa, el error aparece al ejecutar. Los tipos te protegen dentro de tu código, no en la frontera con el exterior.

Validar lo que llega

Si los datos vienen de un servicio que no controlas, compruébalos. Una forma sencilla es una función con un predicado de tipo (valor is Ruta), que viste en Estrechar tipos:

function esRuta(valor: unknown): valor is Ruta {
  if (typeof valor !== "object" || valor === null) return false;
  const v = valor as Record<string, unknown>;
  return (
    typeof v.id === "number" &&
    typeof v.nombre === "string" &&
    typeof v.km === "number" &&
    (v.dificultad === "baja" || v.dificultad === "media" || v.dificultad === "alta")
  );
}

async function cargarRutasSeguro(): Promise<Ruta[]> {
  const datos: unknown = await obtenerJSON<unknown>("/api/rutas.json");
  if (!Array.isArray(datos) || !datos.every(esRuta)) {
    throw new Error("El servidor ha enviado datos con un formato inesperado");
  }
  return datos;
}

Así el any se convierte en unknown (que obliga a comprobar) y solo pasa a Ruta[] después de validar. En proyectos grandes se usan librerías como Zod, que generan esta validación y el tipo a la vez a partir de un esquema.

Crear elementos

document.createElement también conoce las etiquetas, así que el resultado sale con su tipo exacto:

function pintarRutas(lista: HTMLUListElement, rutas: Ruta[]): void {
  lista.replaceChildren();

  for (const ruta of rutas) {
    const li = document.createElement("li"); // HTMLLIElement
    li.textContent = `${ruta.nombre} (${ruta.km} km)`;
    li.dataset.id = String(ruta.id);
    li.classList.add(`dificultad-${ruta.dificultad}`);
    lista.append(li);
  }
}

Dos detalles: dataset solo guarda textos (por eso el String(ruta.id)), y al leerlo obtienes string | undefined, porque el atributo podría no existir. Si lo necesitas como número, conviértelo con Number(...) al leer.

Consejo: para mostrar textos que vienen del usuario o de un servidor, usa textContent y no innerHTML. Así evitas que alguien cuele HTML o scripts en tu página (un ataque llamado XSS). TypeScript no te protege de esto.

Errores frecuentes

  • Property 'value' does not exist on type 'Element': has usado querySelector con un selector CSS. Indica el tipo: querySelector<HTMLInputElement>("#nombre").
  • 'x' is possibly 'null': el elemento podría no existir. Compruébalo con if, lanza un error o, si estás seguro, usa !.
  • Llenar el código de ! y as: cada uno es un punto donde TypeScript deja de vigilar. Si un elemento puede faltar de verdad, compruébalo.
  • Usar evento.target como si fuera el elemento: es EventTarget | null. Compruébalo con instanceof HTMLElement o usa la variable del elemento.
  • Olvidar que value es un string: plazas.value + 1 concatena. Usa valueAsNumber o Number(...).
  • Creer que tipar fetch valida los datos: Promise<Ruta[]> no comprueba nada. Si los datos no son de fiar, valídalos.
  • Ejecutar el script antes de que exista el HTML: todos los querySelector darán null. Carga el script con type="module" o defer.

Resumen

ConceptoEjemplo
Activar los tipos del navegador"lib": ["es2022", "dom", "dom.iterable"]
Por idgetElementById("x") → HTMLElement | null
Por etiquetaquerySelector("form") → HTMLFormElement | null
Por selector CSSquerySelector("#x") → Element | null
Indicar el tipoquerySelector<HTMLInputElement>("#nombre")
Quitar el null (sin comprobar)querySelector("form")!
Comprobar el tipo de verdadif (el instanceof HTMLInputElement)
Evento inferidoaddEventListener("keydown", (e) => ...) → KeyboardEvent
Función de evento apartefunction f(e: KeyboardEvent): void
Origen del eventoe.target es EventTarget | null: usa instanceof
Valores de formulariovalue es string; valueAsNumber, checked
FormDataget() → FormDataEntryValue | null
fetch tipadoasync function f(): Promise<Ruta[]> (no valida)
ValidarPredicado valor is Ruta sobre unknown

En la siguiente lección juntas todo el curso en un proyecto: una lista de tareas con TypeScript.

Pon a prueba lo que has aprendido

[TypeScript] ¿Por qué da error nombre.value?
const nombre = document.querySelector("#nombre");
if (nombre) {
  console.log(nombre.value);
}

[TypeScript] Dentro de un addEventListener("click") sobre una lista, evento.target es EventTarget | null. ¿Cómo lo usas con seguridad como elemento?

[TypeScript] Anotas async function cargar(): Promise<Ruta[]> y devuelves respuesta.json(). El servidor envía un formato distinto. ¿Qué pasa?

[TypeScript] Si plazas es un HTMLInputElement con valor 2, ¿qué valor tiene plazas.value + 1?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.