Skip to content

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/node

Si 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 funcione

Vite 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');  // HTMLInputElement

Non-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 ​

  1. Exporta desde tipos.ts una interface Tarea y una constante PRIORIDADES, e impórtalas en otro archivo usando import type para la interface.
  2. 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.
  3. En una lista <ul id="tareas"> con botones <button data-id="3">, usa delegación de eventos y instanceof para obtener el data-id del botón clicado.
  4. Declara en un .d.ts que window tiene una propiedad APP_VERSION de tipo string.
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 type para importaciones solo de tipos.
  • Los .d.ts describen código JS existente; @types/paquete trae tipos de la comunidad.
  • declare module y declare global te permiten tipar librerías y globales.
  • querySelector<HTMLInputElement>('#id') indica el tipo de elemento; siempre puede ser null.
  • Evita ! y as para silenciar null: comprueba o lanza un error descriptivo.
  • Los eventos se tipan solos por su nombre; estrecha e.target con instanceof.

Siguiente: TS con React y Node

Academia Web — aprende, copia y construye.