Tema
Módulos y DOM
En proyectos reales tu código vive en muchos archivos y casi siempre interactúa con el navegador. En esta lección aprenderás a compartir tipos entre archivos, a usar librerías que no traen tipos (con @types y archivos .d.ts) y a trabajar con el DOM de forma segura: seleccionar elementos, leer inputs y manejar eventos. Cerramos con un mini formulario tipado.
Concepto
TypeScript usa los mismos módulos ES que JavaScript (import / export, ver Módulos ES). La novedad es que, además de valores, puedes exportar e importar tipos.
Con el DOM, TypeScript conoce cada etiqueta HTML y sus propiedades gracias a la librería DOM del tsconfig. Sabe que un <input> tiene value, que un <img> tiene src y que querySelector puede devolver null si no encuentra nada.
Exportar e importar tipos
ts
// src/tipos/producto.ts
export interface Producto {
id: number;
nombre: string;
precio: number;
}
export type Categoria = 'ropa' | 'hogar' | 'tecnologia';
export const CATEGORIAS: Categoria[] = ['ropa', 'hogar', 'tecnologia'];ts
// src/carrito.ts
import { CATEGORIAS, type Producto, type Categoria } from './tipos/producto';
// O importar SOLO tipos con import type
import type { Producto as P } from './tipos/producto';
export function total(productos: Producto[]): number {
return productos.reduce((suma, p) => suma + p.precio, 0);
}¿Por qué import type?
Los tipos se borran al compilar. import type le dice explícitamente al compilador (y a herramientas como Vite o esbuild) que esa importación solo existe para TypeScript y que puede eliminarse por completo del JavaScript final. Esto:
- Evita importar archivos innecesarios en el bundle.
- Evita errores con herramientas que compilan archivo por archivo.
- Deja claro, al leer el código, qué es tipo y qué es valor.
Buena práctica
Activa "verbatimModuleSyntax": true en tu tsconfig.json. TS te obligará a usar import type (o type en línea) para todo lo que sea solo tipo. Vite lo recomienda en sus plantillas.
Dónde poner los tipos
- Tipos usados en un solo archivo: déjalos en ese archivo.
- Tipos compartidos de un dominio: un archivo junto al código (
src/productos/producto.types.ts). - Tipos globales de la app:
src/types/con archivos por tema (api.ts,usuario.ts).
Archivos .d.ts y @types
Un archivo .d.ts (declaration file) contiene solo tipos, sin código ejecutable. Describe la forma de código JavaScript que ya existe.
Muchas librerías traen sus tipos incluidos (Zod, Axios, Vue, Vite). Otras, escritas en JavaScript, tienen tipos mantenidos por la comunidad en el proyecto DefinitelyTyped, que se instalan con el prefijo @types/:
bash
# La librería trae tipos: no necesitas nada más
npm i axios
# La librería NO trae tipos: instala los de la comunidad
npm i express
npm i -D @types/express
npm i lodash
npm i -D @types/lodash
# Tipos de Node (process, fs, path, Buffer...)
npm i -D @types/nodeSi importas una librería sin tipos, TS mostrará: Could not find a declaration file for module 'x'. La solución es buscar @types/x o, si no existe, declararla tú mismo.
Escribir tus propias declaraciones
ts
// src/types/globales.d.ts
// 1. Una librería sin tipos: la declaras como módulo
declare module 'libreria-vieja' {
export function formatear(texto: string): string;
}
// 2. Archivos que importas con Vite (imágenes, CSS modules)
declare module '*.svg' {
const url: string;
export default url;
}
// 3. Variables globales inyectadas por un script externo
declare global {
interface Window {
dataLayer: unknown[]; // Google Tag Manager
}
}
export {}; // convierte el archivo en módulo para que declare global funcioneVite y las variables de entorno
Las plantillas de Vite incluyen src/vite-env.d.ts, que tipa import.meta.env. Puedes agregar tus variables extendiendo la interface ImportMetaEnv con, por ejemplo, readonly VITE_API_URL: string.
TypeScript con el DOM
querySelector y sus tipos
ts
// Con una etiqueta, TS deduce el tipo exacto
const titulo = document.querySelector('h1'); // HTMLHeadingElement | null
const enlace = document.querySelector('a'); // HTMLAnchorElement | null
const input = document.querySelector('input'); // HTMLInputElement | null
// Con un selector de clase o id, TS no puede saber qué es
const caja = document.querySelector('.caja'); // Element | null
const email = document.querySelector('#email'); // Element | null
// Indícalo con el genérico de querySelector
const campoEmail = document.querySelector<HTMLInputElement>('#email');
// HTMLInputElement | null
const botones = document.querySelectorAll<HTMLButtonElement>('.btn');
// NodeListOf<HTMLButtonElement>
// getElementById siempre devuelve HTMLElement | null
const app = document.getElementById('app');Tipos de elementos más comunes: HTMLInputElement, HTMLButtonElement, HTMLFormElement, HTMLSelectElement, HTMLTextAreaElement, HTMLImageElement, HTMLAnchorElement, HTMLDivElement, HTMLElement (genérico).
Siempre puede ser null
ts
const campo = document.querySelector<HTMLInputElement>('#email');
campo.value = 'hola';
// ❌ 'campo' is possibly 'null'.
// ✅ Opción 1: comprobar
if (campo) {
campo.value = 'hola';
}
// ✅ Opción 2: fallar con un mensaje claro si falta
function $<T extends Element>(selector: string): T {
const el = document.querySelector<T>(selector);
if (!el) throw new Error(`No se encontró el elemento: ${selector}`);
return el;
}
const campo2 = $<HTMLInputElement>('#email'); // HTMLInputElementNon-null assertion (!) y por qué evitarla
El operador ! le dice a TS "confía en mí, esto no es null":
ts
const boton = document.querySelector('#comprar')!; // Element (sin null)Es tentador, pero apaga la protección. Si mañana alguien cambia el id en el HTML, el error aparecerá en producción como Cannot read properties of null, sin ninguna pista de qué elemento faltaba.
Error común
Llenar el código de ! para que "desaparezcan los errores rojos". Cada ! es un posible bug silencioso. Prefiere comprobar con if o usar una función como $ que lance un error descriptivo.
Lo mismo aplica a as: document.querySelector('#x') as HTMLInputElement borra el null y además puede mentir sobre el tipo de elemento.
Eventos tipados
ts
const boton = document.querySelector<HTMLButtonElement>('#comprar');
// TS deduce el tipo del evento por su nombre: 'click' → MouseEvent
boton?.addEventListener('click', (evento) => {
console.log(evento.clientX, evento.clientY);
});
// 'keydown' → KeyboardEvent
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cerrarModal();
});
// 'submit' → SubmitEvent
const form = document.querySelector<HTMLFormElement>('#contacto');
form?.addEventListener('submit', (e) => {
e.preventDefault();
});
// event.target es EventTarget | null: hay que estrecharlo
const lista = document.querySelector<HTMLUListElement>('#lista');
lista?.addEventListener('click', (e) => {
const objetivo = e.target;
if (objetivo instanceof HTMLButtonElement) {
console.log('Borrar', objetivo.dataset.id);
}
});
// Manejadores definidos aparte
function alEscribir(e: Event) {
const input = e.currentTarget as HTMLInputElement; // currentTarget es el elemento del listener
console.log(input.value);
}
function cerrarModal(): void {
document.querySelector('.modal')?.classList.remove('abierto');
}e.target es el elemento donde ocurrió el evento (puede ser un hijo); e.currentTarget es el elemento que tiene el listener. Con delegación de eventos usa instanceof para estrechar target de forma segura. Repasa eventos en El DOM.
Caso real: mini formulario tipado
HTML:
html
<form id="registro">
<input id="nombre" name="nombre" placeholder="Nombre" />
<input id="email" name="email" type="email" placeholder="Correo" />
<select id="plan" name="plan">
<option value="basico">Básico</option>
<option value="pro">Pro</option>
</select>
<button type="submit">Registrarme</button>
<p id="mensaje"></p>
</form>TypeScript:
ts
// src/registro.ts
type Plan = 'basico' | 'pro';
interface DatosRegistro {
nombre: string;
email: string;
plan: Plan;
}
type Errores = Partial<Record<keyof DatosRegistro, string>>;
// Selector seguro: lanza un error descriptivo si falta el elemento
function $<T extends Element>(selector: string): T {
const el = document.querySelector<T>(selector);
if (!el) throw new Error(`Falta el elemento ${selector}`);
return el;
}
const form = $<HTMLFormElement>('#registro');
const inputNombre = $<HTMLInputElement>('#nombre');
const inputEmail = $<HTMLInputElement>('#email');
const selectPlan = $<HTMLSelectElement>('#plan');
const mensaje = $<HTMLParagraphElement>('#mensaje');
function esPlan(valor: string): valor is Plan {
return valor === 'basico' || valor === 'pro';
}
function leerFormulario(): DatosRegistro {
const plan = selectPlan.value;
return {
nombre: inputNombre.value.trim(),
email: inputEmail.value.trim().toLowerCase(),
plan: esPlan(plan) ? plan : 'basico'
};
}
function validar(datos: DatosRegistro): Errores {
const errores: Errores = {};
if (datos.nombre.length < 2) errores.nombre = 'Escribe tu nombre';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(datos.email)) {
errores.email = 'Correo inválido';
}
return errores;
}
function mostrarMensaje(texto: string, tipo: 'ok' | 'error'): void {
mensaje.textContent = texto;
mensaje.style.color = tipo === 'ok' ? 'green' : 'crimson';
}
form.addEventListener('submit', async (e: SubmitEvent) => {
e.preventDefault();
const datos = leerFormulario();
const errores = validar(datos);
const primerError = Object.values(errores)[0];
if (primerError) {
mostrarMensaje(primerError, 'error');
return;
}
try {
const res = await fetch('/api/registro', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
});
if (!res.ok) throw new Error(`Error ${res.status}`);
mostrarMensaje(`¡Bienvenido, ${datos.nombre}!`, 'ok');
form.reset();
} catch (error) {
mostrarMensaje(error instanceof Error ? error.message : 'Error desconocido', 'error');
}
});Fíjate cómo Errores se deriva de DatosRegistro: si agregas un campo telefono, el objeto de errores lo acepta automáticamente.
Ejercicios
- Exporta desde
tipos.tsuna interfaceTareay una constantePRIORIDADES, e impórtalas en otro archivo usandoimport typepara la interface. - Selecciona un
<input id="buscador">con el genérico correcto y, al escribir (input), muestra en consola su valor en minúsculas. Maneja el caso en que no exista. - En una lista
<ul id="tareas">con botones<button data-id="3">, usa delegación de eventos yinstanceofpara obtener eldata-iddel botón clicado. - Declara en un
.d.tsquewindowtiene una propiedadAPP_VERSIONde tipostring.
Ver solución
ts
// 1 — tipos.ts
export interface Tarea {
id: number;
titulo: string;
prioridad: 'alta' | 'media' | 'baja';
}
export const PRIORIDADES = ['alta', 'media', 'baja'] as const;
// otro.ts
import type { Tarea } from './tipos';
import { PRIORIDADES } from './tipos';
const t: Tarea = { id: 1, titulo: 'Diseñar logo', prioridad: PRIORIDADES[0] };
// 2
const buscador = document.querySelector<HTMLInputElement>('#buscador');
if (buscador) {
buscador.addEventListener('input', () => {
console.log(buscador.value.toLowerCase());
});
} else {
console.warn('No existe #buscador');
}
// 3
const lista = document.querySelector<HTMLUListElement>('#tareas');
lista?.addEventListener('click', (e) => {
const boton = e.target instanceof Element ? e.target.closest('button') : null;
if (boton instanceof HTMLButtonElement) {
console.log('id:', Number(boton.dataset.id));
}
});
// 4 — src/types/globales.d.ts
declare global {
interface Window {
APP_VERSION: string;
}
}
export {};Resumen
- Exporta e importa tipos como cualquier valor; usa
import typepara importaciones solo de tipos. - Los
.d.tsdescriben código JS existente;@types/paquetetrae tipos de la comunidad. declare moduleydeclare globalte permiten tipar librerías y globales.querySelector<HTMLInputElement>('#id')indica el tipo de elemento; siempre puede sernull.- Evita
!yaspara silenciarnull: comprueba o lanza un error descriptivo. - Los eventos se tipan solos por su nombre; estrecha
e.targetconinstanceof.
Siguiente: TS con React y Node