Skip to content

📙 Clase 14 — Manipular el DOM con TypeScript (querySelector y tipos)

TypeScript · 2026-08-06 · Carpeta: 02-Ejercicios/DOM ⬅️ Volver al índice de clases

🎯 Qué aprendí

  • Cómo seleccionar elementos del HTML con document.querySelector por etiqueta, clase (.title) o ID (#message), y por qué en los tres casos el tipo que regresa TypeScript es genérico (Element | null), no el tipo real del elemento.
  • Por qué hace falta una type assertion (as HTMLInputElement, etc.) para acceder a propiedades específicas de cada tipo de elemento (.value, .placeholder, .disabled…) — sin ella, TypeScript ni siquiera deja compilar.
  • Por qué el operador de encadenamiento opcional (?.) resuelve el problema del posible null, pero no resuelve el problema del tipo específico — son dos problemas distintos con dos soluciones distintas.
  • Cómo usar tsc archivo.ts --watch (o -w) para que el compilador recompile solo cada vez que guardo, sin tener que correr tsc a mano una y otra vez.
  • Que se puede declarar una variable con un tipo amplio (HTMLElement) y asignarle después un valor de un tipo más específico (HTMLButtonElement) usando as.
  • Un detalle que corregí en mi propio código: HTMLHeadElement no es lo mismo que HTMLHeadingElement (ver sección 3 de la teoría).

📚 Definiciones clave

TérminoQué esEjemplo de esta clase
document.querySelector(selector)Busca el primer elemento que cumple el selector CSS; devuelve Element | nulldocument.querySelector('#message')
as HTMLInputElementType assertion: le dice a TypeScript el tipo real del elemento, para poder usar sus propiedades específicas... as HTMLInputElement
?. (optional chaining)Accede a una propiedad solo si el valor no es null/undefined; si lo es, da undefined en vez de tronarelemento?.textContent
tsc archivo.ts --watch / -wRecompila automáticamente cada vez que guardas el .tstsc main.ts -w

📖 PARTE TEÓRICA

🔍 1. querySelector: tres formas de seleccionar un elemento

document.querySelector acepta cualquier selector CSS — por etiqueta, clase (con .) o ID (con #):

typescript
// Por nombre de etiqueta
const h1 = document.querySelector('h1');

// Por clase
const titulo = document.querySelector('.title') as HTMLHeadingElement;

// Por ID
const message = document.querySelector('#message') as HTMLInputElement;

En los tres casos, sin importar el selector, querySelector en TypeScript devuelve Element | null — el tipo más genérico posible. TypeScript no tiene forma de saber, solo mirando el string '#message', que ese ID corresponde a un <input> en tu HTML; esa relación solo existe en tu cabeza y en el archivo .html, no en el tipo.

🧪 Entrevista: ¿Qué tipo devuelve document.querySelector()? Siempre Element | null, sin importar el selector que uses — TypeScript no analiza tu HTML para inferir el tipo real del elemento.

🎭 2. Type assertion: decirle a TypeScript qué tipo esperas

Como querySelector no puede inferir el tipo específico, se usa as (type assertion) para indicárselo tú mismo:

typescript
const message = document.querySelector('#message') as HTMLInputElement;
console.log(message.placeholder); // ahora sí: placeholder existe en HTMLInputElement

TypeScript trae interfaces específicas para casi cualquier elemento HTML: HTMLInputElement (inputs), HTMLButtonElement (botones), HTMLHeadingElement (h1h6), y muchas más — cada una con las propiedades reales de ese elemento (.value, .disabled, .placeholder…).

Verificado en terminal: sin la asignación, acceder a una propiedad específica es un error de compilación, no de ejecución — nunca llegas a correr el código roto:

zsh · sin-asercion.ts
$ tsc sin-asercion.ts
error TS18047: 'message' is possibly 'null'.
error TS2339: Property 'placeholder' does not exist on type 'Element'.

📝 3. Un errata corregida: HTMLHeadElementHTMLHeadingElement

Al escribir el ejercicio me equivoqué con un nombre parecido, y vale la pena dejarlo documentado porque es un error fácil de repetir:

TipoEs para¿Sirve para <h1 class="title">?
HTMLHeadElementLa etiqueta <head> del documento (metadatos, no contenido visible)❌ No
HTMLHeadingElementLos encabezados <h1> a <h6>✅ Sí
typescript
// ❌ compila, pero el tipo es semánticamente incorrecto
let titulo: HTMLHeadElement = document.querySelector('.title') as HTMLHeadElement;

// ✅ el tipo correcto para un <h1>
let titulo: HTMLHeadingElement = document.querySelector('.title') as HTMLHeadingElement;

⚠️ TypeScript no marca error en la versión incorrecta — HTMLHeadElement y HTMLHeadingElement son primos lejanos (ambos heredan de HTMLElement), así que la aserción as pasa sin quejarse. El problema es semántico, no de compilación: le estás mintiendo al lector de tu código sobre qué representa esa variable. Vale la pena revisar el nombre exacto del tipo con autocompletado de VS Code antes de escribirlo.

❔ 4. ?. resuelve el null, pero no el tipo

Estos son dos problemas distintos que suelen aparecer juntos, y confundirlos genera errores raros. Verificado en terminal, paso a paso:

typescript
const message = document.querySelector('#message');
console.log(message.placeholder);

dos errores: TS18047 (posible null) y TS2339 (la propiedad no existe en Element).

typescript
const message = document.querySelector('#message');
console.log(message?.placeholder);

→ el ?. resuelve el null, pero sigue quedando TS2339Element sigue sin tener .placeholder, sin importar si accedes con . o con ?..

typescript
const message = document.querySelector('#message') as HTMLInputElement;
console.log(message?.placeholder);

→ compila limpio. La as resuelve el tipo; el ?. resuelve el null. Necesitas ambos cuando el elemento podría no existir Y quieres una propiedad específica.

💡 Piensa en ?. como "¿existe esto?" y en as Tipo como "¿qué es esto?" — son preguntas distintas, y querySelector deja las dos abiertas a la vez.

🔁 5. Modo --watch: recompilar sin correr tsc a mano

Escribir código, compilar, revisar el navegador, repetir — hacerlo a mano con tsc archivo.ts cada vez se vuelve tedioso. El modo watch automatiza el paso de compilar:

bash
tsc main.ts --watch
# o, abreviado:
tsc main.ts -w

Verificado en terminal: dejé el watcher corriendo, edité el .ts sin tocar la terminal para nada, y el .js se actualizó solo:

zsh · tsc --watch
$ tsc main.ts --watch
Starting compilation in watch mode...
Found 0 errors. Watching for file changes.
← (edito y guardo el .ts en otra ventana, sin tocar esta terminal)
File change detected. Starting incremental compilation...
Found 0 errors. Watching for file changes.

💡 El watcher se queda corriendo (no regresa el control de la terminal) hasta que lo detienes con Ctrl+C. El flujo cómodo: una terminal integrada de VS Code con tsc -w corriendo, tu editor al lado, y el navegador en otra pestaña con recarga manual o Live Server — igual que viste en la Clase 12.

🔼 6. Dato curioso: de un tipo amplio a uno específico

Se puede declarar una variable con el tipo genérico HTMLElement y, más adelante, asignarle un valor de un tipo más concreto usando as:

typescript
let elemento: HTMLElement;
elemento = document.querySelector('button') as HTMLButtonElement; // de HTMLElement a HTMLButtonElement

Funciona porque HTMLButtonElement extiende HTMLElement — es válido asignar algo más específico a una variable declarada como algo más general (todo botón ES un HTMLElement, aunque no todo HTMLElement sea un botón). Es la misma idea de jerarquía de tipos que ya viste con las interfaces en la Clase 5.


💻 PARTE PRÁCTICA

Archivos trabajados: 02-Ejercicios/DOM/index.html y 02-Ejercicios/DOM/main.ts

typescript
// main.ts
const h1 = document.querySelector('h1');
console.log(h1?.textContent);

let title: HTMLHeadingElement = document.querySelector('.title') as HTMLHeadingElement;
console.log(title?.textContent);

const message = document.querySelector('#message') as HTMLInputElement;
console.log(message?.placeholder);

console.log('Hola de nuevo');
bash
cd 02-Ejercicios/DOM
tsc main.ts

Salida real verificada — servido en localhost real y confirmada en la consola de Chrome:

Chrome DevTools · Console
Mi curso de Typescript en Platzi
Mi curso de Typescript en Platzi
Mensaje del usuario
Hola de nuevo

Todo verificado y corriendo. ✅


🏋️ EJERCICIOS CON SOLUCIÓN

Ejercicio 1 — Seleccionar por etiqueta y mostrar el texto

Selecciona el <h1> del HTML con querySelector('h1') y muestra su .textContent en consola, usando ?. para evitar el error de null.

💡 ¿Sabías que…? — `textContent` incluye TODO el texto, hasta el de hijos anidados

.textContent junta el texto de un elemento y de todos sus elementos hijos, sin importar cuántos niveles de anidación tengan — a diferencia de mirar solo el texto directo. Ejemplo de referencia:

typescript
const parrafo = document.querySelector('p');
console.log(parrafo?.textContent); // el texto completo, aunque tenga <span> adentro
Ver solución
typescript
const h1 = document.querySelector('h1');
console.log(h1?.textContent);

Consola esperada (con el HTML de la práctica):

Console
Mi curso de Typescript en Platzi

Ejercicio 2 — Seleccionar por clase con el tipo correcto

Selecciona .title con querySelector y asígnalo a una variable tipada HTMLHeadingElement (el tipo correcto para un <h1>, no HTMLHeadElement).

💡 ¿Sabías que…? — cada etiqueta HTML tiene su propia interfaz en TypeScript

TypeScript trae una interfaz distinta para casi cada etiqueta HTML — HTMLParagraphElement para <p>, HTMLAnchorElement para <a>, HTMLHeadingElement para <h1><h6>. Todas extienden HTMLElement, que a su vez extiende Element. Ejemplo de referencia:

typescript
const enlace = document.querySelector('a') as HTMLAnchorElement;
console.log(enlace?.href); // .href solo existe en HTMLAnchorElement
Ver solución
typescript
let titulo: HTMLHeadingElement = document.querySelector('.title') as HTMLHeadingElement;
console.log(titulo?.textContent);

Terminal esperada:

Console
Mi curso de Typescript en Platzi

Ejercicio 3 — Seleccionar por ID y leer una propiedad específica

Selecciona #message con el tipo HTMLInputElement y muestra su .placeholder en consola.

💡 ¿Sabías que…? — `HTMLInputElement` tiene propiedades que `Element` no tiene

.value, .placeholder, .disabled, .checked… son propiedades que solo existen en HTMLInputElement, no en el tipo genérico Element. Por eso la aserción as es obligatoria aquí — sin ella, TypeScript ni te deja escribir .placeholder. Ejemplo de referencia:

typescript
const casilla = document.querySelector('#acepto') as HTMLInputElement;
console.log(casilla?.checked);
Ver solución
typescript
const message = document.querySelector('#message') as HTMLInputElement;
console.log(message?.placeholder);

Terminal esperada:

Console
Mensaje del usuario

Ejercicio 4 — Forzar los dos errores de saltarte ?. y as

Antes de correrlo: si seleccionas #message SIN as HTMLInputElement y SIN ?., y accedes a .placeholder, ¿cuántos errores esperas? Anticípalo y compruébalo.

💡 ¿Sabías que…? — `querySelector` deja DOS problemas abiertos a la vez

Element | null son dos riesgos distintos en un solo tipo: "podría no existir" (null) y "no sé qué tan específico es" (Element genérico). Resolver solo uno dejará el otro error en pie. Ejemplo de referencia: la sección 4 de la teoría muestra los tres casos (sin nada, solo ?., as + ?.) verificados en terminal.

Ver solución
typescript
const message = document.querySelector('#message');
console.log(message.placeholder);

Terminal real — dos errores, uno por cada problema sin resolver:

zsh · sin-asercion.ts
$ tsc sin-asercion.ts
error TS18047: 'message' is possibly 'null'.
error TS2339: Property 'placeholder' does not exist on type 'Element'.

💡 Agregar solo ?. quita el primer error (TS18047) pero deja el segundo (TS2339) — hace falta también el as HTMLInputElement para que desaparezcan los dos.

Ejercicio 5 — Compilar en modo --watch

Deja tsc main.ts --watch corriendo en una terminal. Sin detenerlo, cambia el mensaje de un console.log en main.ts, guarda, y confirma (sin volver a correr tsc) que el .js se actualizó solo.

💡 ¿Sabías que…? — el watcher no libera la terminal hasta `Ctrl+C`

A diferencia de tsc main.ts normal (que compila y termina), --watch deja el proceso corriendo indefinidamente, "escuchando" cambios en el archivo — tu terminal queda ocupada por ese proceso hasta que lo detengas manualmente. Por eso en VS Code conviene usarlo en una terminal integrada dedicada, dejando otra libre para otros comandos.

Ver solución
bash
tsc main.ts --watch

Sin tocar esa terminal, edito y guardo main.ts en el editor. Terminal real, tras guardar:

zsh · tsc --watch
Starting compilation in watch mode...
Found 0 errors. Watching for file changes.
File change detected. Starting incremental compilation...
Found 0 errors. Watching for file changes.

💡 La forma abreviada -w hace exactamente lo mismo: tsc main.ts -w.

Ejercicio 6 — De tipo amplio a tipo específico

Declara let boton: HTMLElement; y, en una línea aparte, asígnale document.querySelector('button') as HTMLButtonElement. Confirma que compila.

💡 ¿Sabías que…? — todo `HTMLButtonElement` ES un `HTMLElement`, no al revés

Los tipos de elementos HTML forman una jerarquía (ElementHTMLElementHTMLButtonElement, etc.). Puedes guardar algo específico en una variable declarada como algo más general — nunca al revés sin una aserción. Ejemplo de referencia:

typescript
let campo: HTMLElement;
campo = document.querySelector('#nombre') as HTMLInputElement; // válido
Ver solución
typescript
let elemento: HTMLElement;
elemento = document.querySelector('button') as HTMLButtonElement;
console.log(elemento);

Verificado: compila sin errores (tsc tipo-amplio.ts → 0 errores).

Ejercicio 7 — Cambiar una propiedad, no solo leerla

Usa #message como HTMLInputElement y, en vez de solo leer .placeholder, cámbialo a un texto nuevo con una asignación (message.placeholder = "...").

💡 ¿Sabías que…? — las propiedades del DOM son de doble vía

A diferencia de .textContent en un <h1> (que puedes leer y también reasignar para cambiar el texto visible), las propiedades tipadas como .placeholder funcionan igual: leerlas te da el valor actual, y asignarles un valor nuevo actualiza el elemento real en la página al instante. Ejemplo de referencia:

typescript
const boton = document.querySelector('button') as HTMLButtonElement;
boton.disabled = true; // deshabilita el botón de verdad, en la página
Ver solución
typescript
const message = document.querySelector('#message') as HTMLInputElement;
message.placeholder = "Escribe tu nombre aquí";
console.log(message.placeholder);

Terminal esperada:

Console
Escribe tu nombre aquí

💡 Si recargas la página verás el nuevo placeholder en el <input> real — no es solo una copia en memoria, es el elemento vivo del DOM.

Ejercicio 8 — HTMLHeadElement vs HTMLHeadingElement: repetir el error a propósito

Declara let titulo: HTMLHeadElement = document.querySelector('h1') as HTMLHeadElement; (el tipo INCORRECTO, a propósito) y confirma que TypeScript no lo marca como error de compilación, aunque semánticamente esté mal.

💡 ¿Sabías que…? — `as` solo valida que los tipos estén "relacionados", no que sean el correcto

Una type assertion (as) le pide a TypeScript que confíe en ti — solo la rechaza si los dos tipos son completamente ajenos entre sí (por ejemplo, as string sobre un number sin pasar por unknown). Como HTMLHeadElement y HTMLHeadingElement comparten un ancestro común (HTMLElement), TypeScript los considera "relacionados" y deja pasar la aserción, aunque sea el tipo incorrecto para ese elemento.

Ver solución
typescript
// compila, pero es SEMÁNTICAMENTE incorrecto (HTMLHeadElement es para <head>, no <h1>)
let titulo: HTMLHeadElement = document.querySelector('h1') as HTMLHeadElement;
console.log(titulo.textContent); // funciona en tiempo de ejecución, mala señal en el código

Verificado: tsc compila sin ningún error — el problema es invisible para el compilador, solo lo detecta un lector humano (o tú mismo, revisando el nombre del tipo con cuidado).

💡 La forma de evitarlo: dejar que el autocompletado de VS Code te muestre las opciones al escribir HTMLHead y elegir la que dice "Heading", no "Head" a secas.

Ejercicio 9 — Script al final del <body> en vez de defer

Quita el atributo defer del <script> en el <head> y, en su lugar, mueve la etiqueta <script src="main.js"></script> al final del <body>, después de todos los elementos. Confirma que querySelector sigue funcionando igual.

💡 ¿Sabías que…? — `defer` y "al final del body" resuelven el MISMO problema

Ambas técnicas garantizan que el HTML ya esté completo cuando tu script corre — solo cambia el mecanismo. defer deja el script en el <head> pero le dice al navegador "ejecútame después de parsear todo el HTML". Poner el <script> al final del <body> logra lo mismo por orden natural: para cuando el navegador llega a esa línea, ya leyó todo lo de arriba. Ya viste el problema que resuelven en la Clase 12: sin ninguna de las dos, querySelector puede devolver null porque el elemento todavía no existe.

Ver solución
html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
</head>
<body>
  <h1 class="title">Mi curso de Typescript en Platzi</h1>
  <label for="message">Escribe algo:</label>
  <input type="text" id="message" placeholder="Mensaje del usuario" />
  <script src="main.js"></script>
</body>
</html>

Terminal/consola esperada — igual que con defer, porque ambas garantizan que el DOM ya existe:

Console
Mi curso de Typescript en Platzi

Ejercicio 10 — Integrador: leer, validar y mostrar con --watch de fondo

Con tsc main.ts --watch corriendo, escribe un script que lea #message, y si su .value está vacío muestre un mensaje de aviso en consola; si no, muestre `Escribiste: ${valor}`. Prueba cambiando el console.log mientras el watcher sigue corriendo, sin recompilar a mano.

💡 ¿Sabías que…? — integrar `--watch` con lógica de DOM es el flujo real de trabajo

Este ejercicio no trae ningún concepto nuevo — junta todo lo de la clase: as HTMLInputElement para el tipo, ?. o una validación explícita para el posible null, y --watch para no tener que recompilar a mano en cada prueba. Es, literalmente, el flujo con el que se trabaja el resto del curso de aquí en adelante.

Ver solución
typescript
const message = document.querySelector('#message') as HTMLInputElement;

if (message?.value.trim() === "") {
  console.log("⚠️ El campo está vacío");
} else {
  console.log(`Escribiste: ${message?.value}`);
}
bash
tsc main.ts --watch

Terminal esperada (watcher de fondo, sin volver a correr tsc a mano):

zsh · tsc --watch
Found 0 errors. Watching for file changes.
File change detected. Starting incremental compilation...
Found 0 errors. Watching for file changes.

❓ Preguntas y respuestas (autoevaluación)

1. ¿Qué tipo devuelve document.querySelector(selector), sin importar el selector?

Siempre Element | null — TypeScript no analiza tu HTML para saber qué tipo específico de elemento corresponde a ese selector.

2. ¿Para qué sirve as HTMLInputElement (o similar) después de un querySelector?

Es una type assertion: le dice a TypeScript el tipo real del elemento, para poder usar sus propiedades específicas (.value, .placeholder, .disabled…) sin error de compilación.

3. ¿Qué problema resuelve ?. (optional chaining) al usar querySelector?

El de que el elemento podría no existir (querySelector devuelve null si no encuentra nada) — ?. evita el error accediendo de forma segura, devolviendo undefined en vez de tronar.

4. Si uso ?. pero NO uso as Tipo, ¿desaparecen todos los errores de compilación?

No. ?. solo resuelve el problema del posible null (TS18047). Si intentas usar una propiedad específica (como .placeholder) sobre el tipo genérico Element, sigue quedando el error TS2339: Property does not exist.

5. ¿Cuál es la diferencia entre HTMLHeadElement y HTMLHeadingElement?

HTMLHeadElement es el tipo de la etiqueta <head> del documento. HTMLHeadingElement es el tipo correcto para <h1><h6>. Son nombres parecidos pero representan cosas distintas — TypeScript no te avisa si los confundes, porque ambos derivan de HTMLElement.

6. ¿Qué hace tsc archivo.ts --watch (o -w) que no hace tsc archivo.ts normal?

Deja el compilador corriendo de forma continua, recompilando automáticamente cada vez que detecta un cambio guardado en el archivo — sin que tengas que volver a correr tsc a mano.

7. ¿Puedo asignar un valor de tipo HTMLButtonElement a una variable declarada como HTMLElement?

Sí. HTMLButtonElement extiende HTMLElement, así que es válido guardar algo más específico en una variable declarada como algo más general (la relación inversa sí necesitaría una aserción).

8. ¿Qué significa el error TS18047?

Que una variable "posiblemente es null" (o undefined) — típicamente aparece con querySelector cuando accedes a una propiedad sin ?. ni una comprobación previa.

9. ¿defer en el <script> y poner el <script> al final del <body> resuelven el mismo problema?

Sí — ambos garantizan que el HTML ya está completamente cargado antes de que tu script intente hacer querySelector. Sin ninguno de los dos, el script podría correr antes de que el elemento exista, y querySelector devolvería null.

10. ¿Por qué document.querySelector('#message').placeholder (sin ?. ni as) da DOS errores en vez de uno?

Porque son dos problemas distintos en el mismo tipo Element | null: TS18047 (posible null, sin resolver) y TS2339 (.placeholder no existe en el tipo genérico Element, sin resolver). Cada uno necesita su propia solución (?. y as Tipo).

📎 Apuntes relacionados

➡️ Siguiente

Utility typesPartial, Pick, Omit, Record… transformar tipos existentes sin reescribirlos.