TypeScript y el DOM
En esta lección
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 confor...of. En TypeScript 6 ya va dentro dedom, 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.
| Tipo | Propiedades típicas |
|---|---|
HTMLElement | textContent, classList, dataset, hidden, style |
HTMLInputElement | value, valueAsNumber, checked, type, disabled |
HTMLSelectElement | value, selectedIndex, options |
HTMLFormElement | reset(), elements, checkValidity() |
HTMLAnchorElement | href, target |
HTMLImageElement | src, 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#nombreresulta ser un<div>, TypeScript te cree igualmente ynombre.valuevaldráundefinedal 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.
| Forma | Comprueba al ejecutar | Cuándo usarla |
|---|---|---|
if (el) { ... } | Sí | El elemento puede faltar |
el?.prop | Sí | Solo leer, con un valor por defecto |
if (!el) throw ... | Sí | El elemento es imprescindible |
querySelector(...)! | No | Estás seguro y quieres código corto |
querySelector<T>(...) | No (el tipo) | Selector CSS cuyo tipo conoces |
instanceof | Sí (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");
}
});
| Evento | Tipo |
|---|---|
click, dblclick | MouseEvent (o PointerEvent, que lo amplía) |
keydown, keyup | KeyboardEvent |
submit | SubmitEvent |
input | InputEvent o Event, según la versión |
change, load | Event |
focus, blur | FocusEvent |
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} €`);
});
valueAsNumberdevuelve el campo comonumber(oNaNsi está vacío). Es más cómodo queNumber(plazas.value).checkedesboolean.eventoesSubmitEvent;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")devuelveFormDataEntryValue | null, es decir,string,File(si es un campo de archivo) onull(si el campo no existe). Por eso se convierte conString(...).- Un checkbox sin marcar no aparece en el
FormData.has("seguro")da elbooleanque buscas. rutallega comostring, y la interfaz pide"cares" | "oso". La comprobación conifestrecha 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 Tson 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
textContenty noinnerHTML. 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 usadoquerySelectorcon un selector CSS. Indica el tipo:querySelector<HTMLInputElement>("#nombre").'x' is possibly 'null': el elemento podría no existir. Compruébalo conif, lanza un error o, si estás seguro, usa!.- Llenar el código de
!yas: cada uno es un punto donde TypeScript deja de vigilar. Si un elemento puede faltar de verdad, compruébalo. - Usar
evento.targetcomo si fuera el elemento: esEventTarget | null. Compruébalo coninstanceof HTMLElemento usa la variable del elemento. - Olvidar que
valuees unstring:plazas.value + 1concatena. UsavalueAsNumberoNumber(...). - Creer que tipar
fetchvalida 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
querySelectordaránnull. Carga el script contype="module"odefer.
Resumen
| Concepto | Ejemplo |
|---|---|
| Activar los tipos del navegador | "lib": ["es2022", "dom", "dom.iterable"] |
| Por id | getElementById("x") → HTMLElement | null |
| Por etiqueta | querySelector("form") → HTMLFormElement | null |
| Por selector CSS | querySelector("#x") → Element | null |
| Indicar el tipo | querySelector<HTMLInputElement>("#nombre") |
| Quitar el null (sin comprobar) | querySelector("form")! |
| Comprobar el tipo de verdad | if (el instanceof HTMLInputElement) |
| Evento inferido | addEventListener("keydown", (e) => ...) → KeyboardEvent |
| Función de evento aparte | function f(e: KeyboardEvent): void |
| Origen del evento | e.target es EventTarget | null: usa instanceof |
| Valores de formulario | value es string; valueAsNumber, checked |
| FormData | get() → FormDataEntryValue | null |
| fetch tipado | async function f(): Promise<Ruta[]> (no valida) |
| Validar | Predicado 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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.