Proyecto: una lista de tareas con TypeScript
En esta lección
- Qué vamos a construir
- Paso 1: preparar el proyecto
- Paso 2: el HTML
- Paso 3: los tipos del proyecto
- Paso 4: la lógica pura
- Paso 5: validar lo que viene de fuera
- Paso 6: guardar y cargar
- Paso 7: seleccionar los elementos
- Paso 8: el estado y pintar la lista
- Paso 9: modificar los datos sin mutar
- Paso 10: eventos
- Código final completo
- Compilar y probar
- Ideas para ampliarlo
- Errores frecuentes
- 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:
| Archivo | Responsabilidad |
|---|---|
src/tarea.ts | Los tipos y la lógica pura (crear ids, filtrar, validar). No toca el navegador |
src/almacen.ts | Leer y guardar en localStorage |
src/main.ts | Todo 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-tsy copia los archivos desrc/. Cambia el<script>del HTML a/src/main.tsy 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ón | Nueva lista |
|---|---|
| Añadir | [...tareas, nueva] |
| Marcar o desmarcar | tareas.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t)) |
| Borrar una | tareas.filter((t) => t.id !== id) |
| Borrar las hechas | tareas.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
- Editar una tarea: doble clic sobre el texto para cambiarlo. Añade un evento
dblclickcon delegación. - Fecha límite: añade
limite?: stringaTarea(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. - Ordenar por prioridad: una función
ordenar(tareas: Tarea[]): Tarea[]entarea.tscon un objetoRecord<Prioridad, number>para asignar un peso a cada prioridad. - Cargar tareas de una API: una función genérica
obtenerJSON<T>como la de la lección del DOM, validando conesTarea. - 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 ejecutanpx tsc. Failed to load resourceendist/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
.tspero el navegador carga el.jsdedist/. Usatsc --watchy recarga. - Rutas sin
.jsen 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 poractualizar. - Fiarse de
JSON.parse: devuelveany. Valida conesTareaantes de usar los datos. - Usar
innerHTMLcon el texto del usuario: abre la puerta a inyectar HTML. UsatextContent.
Resumen
| Idea | Dónde se ha usado |
|---|---|
| Uniones de literales | Prioridad y Filtro |
| Interfaz | Tarea |
Funciones tipadas y switch exhaustivo | filtrar, resumen, siguienteId |
| Predicados de tipo | esTarea, esPrioridad, esFiltro |
Módulos y import type | tarea.ts, almacen.ts, main.ts |
unknown en vez de any | Lectura de localStorage |
Elementos tipados con instanceof | Función elemento y delegación de eventos |
| Estado inmutable | actualizar con map, filter y spread |
tsconfig.json | strict, 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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.