Saltar al contenido
proyecto.ts · devschool

Proyecto: una lista de tareas con TypeScript

Lección 15 de 15 · 16 min de lectura · Actualizado el

En esta lección
  1. Qué vamos a construir
  2. Paso 1: preparar el proyecto
  3. Paso 2: el HTML
  4. Paso 3: los tipos del proyecto
  5. Paso 4: la lógica pura
  6. Paso 5: validar lo que viene de fuera
  7. Paso 6: guardar y cargar
  8. Paso 7: seleccionar los elementos
  9. Paso 8: el estado y pintar la lista
  10. Paso 9: modificar los datos sin mutar
  11. Paso 10: eventos
  12. Código final completo
  13. Compilar y probar
  14. Ideas para ampliarlo
  15. Errores frecuentes
  16. Resumen

Es hora de juntar todo el curso. En este proyecto construirás una lista de tareas que funciona en el navegador: añades tareas con una prioridad, las marcas como hechas, las borras, las filtras y se guardan solas aunque cierres la página. Cada parte usa algo que ya has visto: tipos y uniones, interfaces, funciones, estrechamiento, módulos y el DOM.

Qué vamos a construir

La aplicación tiene cuatro piezas:

  • Un formulario con el texto de la tarea y su prioridad (alta, media o baja).
  • Una lista donde cada tarea tiene una casilla para marcarla como hecha y un botón para borrarla.
  • Tres filtros: todas, pendientes y hechas.
  • Un resumen (“Quedan 2 tareas pendientes”) y un botón para borrar las hechas.

Para que el código sea fácil de mantener, lo repartimos en tres módulos, cada uno con una única responsabilidad:

ArchivoResponsabilidad
src/tarea.tsLos tipos y la lógica pura (crear ids, filtrar, validar). No toca el navegador
src/almacen.tsLeer y guardar en localStorage
src/main.tsTodo lo que toca el DOM: pintar y reaccionar a eventos

Separar la lógica del DOM tiene una ventaja práctica: las funciones puras se entienden y se prueban sin abrir el navegador.

Paso 1: preparar el proyecto

Crea la carpeta y el package.json, e instala TypeScript:

mkdir tareas-ts
cd tareas-ts
npm init -y
npm install -D typescript
mkdir src

Crea el tsconfig.json (la explicación de cada opción está en Módulos y tsconfig):

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2022", "dom", "dom.iterable"],
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Aquí el navegador será quien ejecute los módulos, así que tsc solo traduce cada .ts de src/ a un .js en dist/. Por eso las importaciones llevan la extensión .js, la que tendrán los archivos tras compilar.

Consejo: si prefieres Vite, crea el proyecto con npm create vite@latest tareas-ts -- --template vanilla-ts y copia los archivos de src/. Cambia el <script> del HTML a /src/main.ts y las rutas de import pueden ir sin extensión.

Paso 2: el HTML

El HTML solo pone la estructura. Lo que cambia, la lista, lo creará TypeScript. Los botones de filtro llevan el filtro en un atributo data-filtro, y aria-pressed indica cuál está activo (también lo leen los lectores de pantalla). Guárdalo como index.html en la raíz (lo tienes completo en el código final, más abajo).

Paso 3: los tipos del proyecto

Empieza siempre por los datos: ¿qué es una tarea? Con eso claro, el resto del código sale solo. En src/tarea.ts:

export type Prioridad = "alta" | "media" | "baja";
export type Filtro = "todas" | "pendientes" | "hechas";

export interface Tarea {
  id: number;
  texto: string;
  prioridad: Prioridad;
  hecha: boolean;
}

Las dos uniones de literales impiden valores inventados: una tarea con prioridad "urgente" ni compila. Y export permite usar los tipos desde los otros módulos.

Paso 4: la lógica pura

Debajo de los tipos van las funciones que trabajan con tareas, sin DOM:

export function siguienteId(tareas: Tarea[]): number {
  return tareas.reduce((max, t) => Math.max(max, t.id), 0) + 1;
}

export function filtrar(tareas: Tarea[], filtro: Filtro): Tarea[] {
  switch (filtro) {
    case "todas":
      return tareas;
    case "pendientes":
      return tareas.filter((t) => !t.hecha);
    case "hechas":
      return tareas.filter((t) => t.hecha);
  }
}

siguienteId busca el id más alto y suma uno, así nunca se repite aunque borres tareas del medio. filtrar usa un switch sobre una unión de literales: como están cubiertos los tres casos, TypeScript sabe que la función siempre devuelve algo y no pide un return final. Si algún día añades el filtro "urgentes" al tipo y olvidas este switch, te avisará.

Paso 5: validar lo que viene de fuera

Los datos de localStorage son texto que alguien (tú, o el navegador de otra versión de tu app) pudo guardar de cualquier manera. Igual que con fetch, el tipo no basta: hay que comprobar. Se hace con predicados de tipo, que ya conoces de Estrechar tipos:

export function esPrioridad(valor: unknown): valor is Prioridad {
  return valor === "alta" || valor === "media" || valor === "baja";
}

export function esTarea(valor: unknown): valor is Tarea {
  if (typeof valor !== "object" || valor === null) return false;
  const v = valor as Record<string, unknown>;
  return (
    typeof v.id === "number" &&
    typeof v.texto === "string" &&
    esPrioridad(v.prioridad) &&
    typeof v.hecha === "boolean"
  );
}

esFiltro es igual que esPrioridad, con los tres filtros. Estas dos las usaremos también con el HTML: el value de un <select> y el data-filtro de un botón son siempre string, y estas funciones los convierten en Prioridad y Filtro de forma segura. Termina el archivo con la función del resumen:

export function resumen(tareas: Tarea[]): string {
  const pendientes = tareas.filter((t) => !t.hecha).length;
  if (tareas.length === 0) return "No hay tareas";
  return pendientes === 1 ? "Queda 1 tarea pendiente" : `Quedan ${pendientes} tareas pendientes`;
}

Paso 6: guardar y cargar

src/almacen.ts es muy corto (está completo al final). Lo importante es que nunca se fía de lo que lee: JSON.parse devuelve any, así que lo guardamos en un unknown y nos quedamos solo con lo que pasa esTarea.

const datos: unknown = JSON.parse(texto);
return Array.isArray(datos) ? datos.filter(esTarea) : [];

El try...catch cubre dos fallos reales: que el almacenamiento esté bloqueado (modo privado, permisos) y que el texto guardado no sea JSON válido. En los dos casos, la aplicación arranca con la lista vacía en vez de romperse. Fíjate también en import { esTarea, type Tarea }: la función es un valor y Tarea solo un tipo.

Paso 7: seleccionar los elementos

Empezamos src/main.ts con los imports, la función de ayuda de la lección del DOM y los elementos que vamos a necesitar:

import { cargar, guardar } from "./almacen.js";
// ...más imports de ./tarea.js (están en el código final)

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 formulario = elemento("#formulario", HTMLFormElement);
const campoTexto = elemento("#texto", HTMLInputElement);
// ...y el resto de elementos (están en el código final)

Al arrancar, cada elemento se comprueba una vez. Si el HTML cambia y falta alguno, la aplicación falla enseguida con un mensaje claro, en lugar de romperse a mitad de un clic. Y después ya no hay null por ningún sitio.

Paso 8: el estado y pintar la lista

El estado de la aplicación son solo dos variables: la lista de tareas y el filtro activo. Todo lo que se ve en pantalla sale de ellas.

let tareas: Tarea[] = cargar();
let filtroActual: Filtro = "todas";

La función pintar borra la lista y la vuelve a crear entera a partir del estado. Es la forma más sencilla de evitar que la pantalla y los datos se desincronicen:

function pintar(): void {
  lista.replaceChildren(...filtrar(tareas, filtroActual).map(crearItem));
  resumenTexto.textContent = resumen(tareas);

  for (const boton of filtros.querySelectorAll("button")) {
    boton.setAttribute("aria-pressed", String(boton.dataset.filtro === filtroActual));
  }
}

crearItem construye un <li> con createElement y asigna el texto con textContent, nunca con innerHTML: aunque alguien escriba <script> como tarea, se mostrará como texto y no se ejecutará. Verás su código completo más abajo.

Paso 9: modificar los datos sin mutar

Cuando cambia algo (añadir, marcar, borrar), no se modifica la lista existente: se crea una nueva y se pasa a una única función, actualizar, que la guarda y vuelve a pintar:

function actualizar(nuevas: Tarea[]): void {
  tareas = nuevas;
  guardar(tareas);
  pintar();
}

Así hay un solo camino para cambiar los datos y es imposible olvidarse de guardar o de repintar. Las operaciones usan métodos de array que devuelven arrays nuevos:

AcciónNueva lista
Añadir[...tareas, nueva]
Marcar o desmarcartareas.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t))
Borrar unatareas.filter((t) => t.id !== id)
Borrar las hechastareas.filter((t) => !t.hecha)

Paso 10: eventos

Hay cinco eventos. El del formulario lee los campos, valida y añade. Los demás usan delegación: un solo addEventListener en la lista (o en los filtros) en vez de uno por cada botón, porque los <li> se crean y destruyen continuamente. Y como evento.target es un EventTarget | null, en cada uno comprobamos con instanceof:

lista.addEventListener("click", (evento) => {
  if (!(evento.target instanceof HTMLButtonElement)) return;
  if (evento.target.dataset.accion !== "borrar") return;
  const id = idDe(evento.target);
  if (id === undefined) return;

  actualizar(tareas.filter((t) => t.id !== id));
});

idDe sube desde el elemento pulsado hasta su <li> con closest y lee el data-id. Devuelve number | undefined, así que hay que comprobarlo antes de usarlo: la forma que tiene TypeScript de recordarte que el clic podría no venir de una tarea.

Código final completo

Estos son todos los archivos. Compílalos y ábrelos para comprobar que funciona.

tsconfig.json

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2022", "dom", "dom.iterable"],
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

index.html

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mis tareas</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main>
    <h1>Mis tareas</h1>

    <form id="formulario">
      <input id="texto" name="texto" placeholder="Nueva tarea" required autocomplete="off">
      <select id="prioridad" name="prioridad">
        <option value="alta">Alta</option>
        <option value="media" selected>Media</option>
        <option value="baja">Baja</option>
      </select>
      <button>Añadir</button>
    </form>

    <nav id="filtros" aria-label="Filtrar tareas">
      <button data-filtro="todas" aria-pressed="true">Todas</button>
      <button data-filtro="pendientes" aria-pressed="false">Pendientes</button>
      <button data-filtro="hechas" aria-pressed="false">Hechas</button>
    </nav>

    <ul id="lista"></ul>

    <p id="resumen"></p>
    <button id="borrar-hechas" type="button">Borrar las hechas</button>
  </main>

  <script type="module" src="dist/main.js"></script>
</body>
</html>

estilos.css

body {
  font-family: system-ui, sans-serif;
  background: #f4f1ea;
  color: #222;
  margin: 0;
}

main {
  max-width: 36rem;
  margin: 2rem auto;
  padding: 0 1rem;
}

form {
  display: flex;
  gap: 0.5rem;
}

#texto {
  flex: 1;
}

#filtros {
  display: flex;
  gap: 0.5rem;
  margin: 1rem 0;
}

#filtros button[aria-pressed="true"] {
  background: #1f6f5c;
  color: white;
}

ul {
  list-style: none;
  padding: 0;
}

li {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem;
  border-left: 0.3rem solid #999;
  background: white;
  margin-bottom: 0.4rem;
}

li.prioridad-alta { border-color: #c0392b; }
li.prioridad-media { border-color: #e0a100; }
li.prioridad-baja { border-color: #1f6f5c; }

li span {
  flex: 1;
}

li.hecha span {
  text-decoration: line-through;
  color: #777;
}

src/tarea.ts

export type Prioridad = "alta" | "media" | "baja";
export type Filtro = "todas" | "pendientes" | "hechas";

export interface Tarea {
  id: number;
  texto: string;
  prioridad: Prioridad;
  hecha: boolean;
}

export function esPrioridad(valor: unknown): valor is Prioridad {
  return valor === "alta" || valor === "media" || valor === "baja";
}

export function esFiltro(valor: unknown): valor is Filtro {
  return valor === "todas" || valor === "pendientes" || valor === "hechas";
}

export function esTarea(valor: unknown): valor is Tarea {
  if (typeof valor !== "object" || valor === null) return false;
  const v = valor as Record<string, unknown>;
  return (
    typeof v.id === "number" &&
    typeof v.texto === "string" &&
    esPrioridad(v.prioridad) &&
    typeof v.hecha === "boolean"
  );
}

export function siguienteId(tareas: Tarea[]): number {
  return tareas.reduce((max, t) => Math.max(max, t.id), 0) + 1;
}

export function filtrar(tareas: Tarea[], filtro: Filtro): Tarea[] {
  switch (filtro) {
    case "todas":
      return tareas;
    case "pendientes":
      return tareas.filter((t) => !t.hecha);
    case "hechas":
      return tareas.filter((t) => t.hecha);
  }
}

export function resumen(tareas: Tarea[]): string {
  const pendientes = tareas.filter((t) => !t.hecha).length;
  if (tareas.length === 0) return "No hay tareas";
  return pendientes === 1 ? "Queda 1 tarea pendiente" : `Quedan ${pendientes} tareas pendientes`;
}

src/almacen.ts

import { esTarea, type Tarea } from "./tarea.js";

const CLAVE = "tareas-ts";

export function cargar(): Tarea[] {
  try {
    const texto = localStorage.getItem(CLAVE);
    if (texto === null) return [];
    const datos: unknown = JSON.parse(texto);
    return Array.isArray(datos) ? datos.filter(esTarea) : [];
  } catch {
    return [];
  }
}

export function guardar(tareas: Tarea[]): void {
  try {
    localStorage.setItem(CLAVE, JSON.stringify(tareas));
  } catch {
    console.warn("No se han podido guardar las tareas");
  }
}

src/main.ts

import { cargar, guardar } from "./almacen.js";
import {
  esFiltro,
  esPrioridad,
  filtrar,
  resumen,
  siguienteId,
  type Filtro,
  type Tarea,
} from "./tarea.js";

// --- Selección segura de elementos ---

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 formulario = elemento("#formulario", HTMLFormElement);
const campoTexto = elemento("#texto", HTMLInputElement);
const campoPrioridad = elemento("#prioridad", HTMLSelectElement);
const filtros = elemento("#filtros", HTMLElement);
const lista = elemento("#lista", HTMLUListElement);
const resumenTexto = elemento("#resumen", HTMLParagraphElement);
const botonBorrarHechas = elemento("#borrar-hechas", HTMLButtonElement);

// --- Estado ---

let tareas: Tarea[] = cargar();
let filtroActual: Filtro = "todas";

// --- Pintar la interfaz ---

function crearItem(tarea: Tarea): HTMLLIElement {
  const li = document.createElement("li");
  li.dataset.id = String(tarea.id);
  li.classList.add(`prioridad-${tarea.prioridad}`);
  li.classList.toggle("hecha", tarea.hecha);

  const casilla = document.createElement("input");
  casilla.type = "checkbox";
  casilla.checked = tarea.hecha;
  casilla.setAttribute("aria-label", `Marcar "${tarea.texto}" como hecha`);

  const texto = document.createElement("span");
  texto.textContent = tarea.texto;

  const borrar = document.createElement("button");
  borrar.type = "button";
  borrar.dataset.accion = "borrar";
  borrar.textContent = "Borrar";

  li.append(casilla, texto, borrar);
  return li;
}

function pintar(): void {
  lista.replaceChildren(...filtrar(tareas, filtroActual).map(crearItem));
  resumenTexto.textContent = resumen(tareas);

  for (const boton of filtros.querySelectorAll("button")) {
    boton.setAttribute("aria-pressed", String(boton.dataset.filtro === filtroActual));
  }
}

function actualizar(nuevas: Tarea[]): void {
  tareas = nuevas;
  guardar(tareas);
  pintar();
}

function idDe(el: HTMLElement): number | undefined {
  const li = el.closest("li");
  return li?.dataset.id === undefined ? undefined : Number(li.dataset.id);
}

// --- Eventos ---

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

  const texto = campoTexto.value.trim();
  if (texto === "") return;

  const prioridad = esPrioridad(campoPrioridad.value) ? campoPrioridad.value : "media";
  const nueva: Tarea = { id: siguienteId(tareas), texto, prioridad, hecha: false };

  actualizar([...tareas, nueva]);
  formulario.reset();
  campoTexto.focus();
});

lista.addEventListener("change", (evento) => {
  if (!(evento.target instanceof HTMLInputElement)) return;
  const id = idDe(evento.target);
  if (id === undefined) return;

  actualizar(tareas.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t)));
});

lista.addEventListener("click", (evento) => {
  if (!(evento.target instanceof HTMLButtonElement)) return;
  if (evento.target.dataset.accion !== "borrar") return;
  const id = idDe(evento.target);
  if (id === undefined) return;

  actualizar(tareas.filter((t) => t.id !== id));
});

filtros.addEventListener("click", (evento) => {
  if (!(evento.target instanceof HTMLButtonElement)) return;
  const filtro = evento.target.dataset.filtro;
  if (!esFiltro(filtro)) return;

  filtroActual = filtro;
  pintar();
});

botonBorrarHechas.addEventListener("click", () => {
  actualizar(tareas.filter((t) => !t.hecha));
});

pintar();

Compilar y probar

Compila una vez, o déjalo vigilando mientras trabajas:

npx tsc
npx tsc --watch

Los módulos ES no se cargan desde file://, así que necesitas un servidor local. Lo más rápido:

npx serve .

Abre la dirección que te muestre (algo como http://localhost:3000). Añade un par de tareas, marca una, prueba los filtros y recarga la página: las tareas siguen ahí. Para ver el localStorage, abre las herramientas de desarrollo del navegador (F12) y mira la pestaña Aplicación (o Almacenamiento).

Ideas para ampliarlo

  1. Editar una tarea: doble clic sobre el texto para cambiarlo. Añade un evento dblclick con delegación.
  2. Fecha límite: añade limite?: string a Tarea (un campo opcional) y un <input type="date">. Piensa en qué funciones tendrás que cambiar y deja que el compilador te las señale.
  3. Ordenar por prioridad: una función ordenar(tareas: Tarea[]): Tarea[] en tarea.ts con un objeto Record<Prioridad, number> para asignar un peso a cada prioridad.
  4. Cargar tareas de una API: una función genérica obtenerJSON<T> como la de la lección del DOM, validando con esTarea.
  5. Pasarlo a clases: convierte el estado y las operaciones en una clase ListaTareas.

Errores frecuentes

  • La página sale en blanco y la consola dice Failed to resolve module specifier: has abierto el HTML con doble clic (file://) o falta compilar. Usa un servidor local y ejecuta npx tsc.
  • Failed to load resource en dist/main.js: el <script> apunta a una ruta que no existe, o no has compilado todavía.
  • Los cambios no aparecen: estás editando el .ts pero el navegador carga el .js de dist/. Usa tsc --watch y recarga.
  • Rutas sin .js en los imports: con esta configuración el navegador necesita la extensión. Escribe "./tarea.js".
  • Mutar la lista (tareas.push(...)) y olvidarse de pintar: pasa siempre por actualizar.
  • Fiarse de JSON.parse: devuelve any. Valida con esTarea antes de usar los datos.
  • Usar innerHTML con el texto del usuario: abre la puerta a inyectar HTML. Usa textContent.

Resumen

IdeaDónde se ha usado
Uniones de literalesPrioridad y Filtro
InterfazTarea
Funciones tipadas y switch exhaustivofiltrar, resumen, siguienteId
Predicados de tipoesTarea, esPrioridad, esFiltro
Módulos y import typetarea.ts, almacen.ts, main.ts
unknown en vez de anyLectura de localStorage
Elementos tipados con instanceofFunción elemento y delegación de eventos
Estado inmutableactualizar con map, filter y spread
tsconfig.jsonstrict, rootDir, outDir, lib con dom

Has terminado el curso de TypeScript. Para seguir, puedes llevar este mismo proyecto a Vite, añadir pruebas, o aplicar lo aprendido con Express para crear una API tipada.

Pon a prueba lo que has aprendido

[TypeScript] ¿Qué ventaja tiene que todos los cambios (añadir, marcar, borrar) pasen por una función actualizar que guarda y vuelve a pintar?

[TypeScript] ¿Por qué almacen.ts guarda el resultado de JSON.parse en una variable unknown y aplica filter(esTarea)?

[TypeScript] ¿Por qué se usa un solo addEventListener en la lista en lugar de uno por cada botón Borrar?

[TypeScript] Abres index.html con doble clic y la aplicación no arranca. ¿Cuál es la causa más probable?

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