Lección 100: 🎉 PROYECTO FINAL — CLI App TODO List en TypeScript
🎯 Objetivo: Construir una aplicación de línea de comandos (CLI) completa en TypeScript que gestione tareas usando archivos JSON. Aplicarás todos los conceptos del curso: tipos, genéricos, clases, interfaces, discriminated unions, type narrowing, módulos, y configuración de proyecto.
📋 Descripción del proyecto
Section titled “📋 Descripción del proyecto”Vamos a crear tareas-cli, un gestor de tareas desde terminal que permite:
- Listar tareas (todas o por estado)
- Agregar nuevas tareas
- Marcar tareas como completadas
- Eliminar tareas
- Filtrar por estado (pendiente/en progreso/completada)
📁 Estructura del proyecto
Section titled “📁 Estructura del proyecto”tareas-cli/├── src/│ ├── index.ts # Entry point: argumentos + dispatch│ ├── task.ts # Lógica de negocio (TaskManager)│ ├── types.ts # Interfaces, tipos y enums│ └── storage.ts # Persistencia en archivo JSON├── package.json├── tsconfig.json└── README.md1. Inicializar el proyecto
Section titled “1. Inicializar el proyecto”mkdir tareas-cli && cd tareas-clinpm init -ynpm install typescript @types/node --save-devnpx tsc --init2. tsconfig.json
Section titled “2. tsconfig.json”{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./src", "declaration": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}3. src/types.ts — Tipos e interfaces
Section titled “3. src/types.ts — Tipos e interfaces”Aquí definimos todos los tipos del dominio.
💻 Ejemplo
/** * Estados posibles de una tarea usando un enum. * El enum genera código JS pero nos da una constante reutilizable. */export enum TaskStatus { PENDIENTE = "pendiente", EN_PROGRESO = "en_progreso", COMPLETADA = "completada",}
/** * Interfaz principal de una tarea. * Usamos genéricos potenciales y un discriminated union en la acción. */export interface Task { id: string; titulo: string; descripcion: string; estado: TaskStatus; creadaEn: Date; actualizadaEn: Date;}
/** * Tipo para crear una tarea (sin id ni fechas). * Usamos Partial y Pick implícitamente. */export type CreateTaskInput = { titulo: string; descripcion?: string;};
/** * Tipo para actualizar una tarea. * Todas las propiedades son opcionales excepto id. */export type UpdateTaskInput = Partial<Omit<Task, "id" | "creadaEn">> & { id: string;};
/** * Acciones del CLI modeladas como discriminated union. * Esto permite type narrowing en el switch. */export type CliAction = | { tipo: "listar"; filtro?: TaskStatus } | { tipo: "agregar"; titulo: string; descripcion?: string } | { tipo: "completar"; id: string } | { tipo: "eliminar"; id: string } | { tipo: "ayuda" };
/** * Posibles errores tipados. */export type TaskError = { codigo: "NO_ENCONTRADA" | "INVALIDO" | "ARCHIVO_CORRUPTO"; mensaje: string;};4. src/storage.ts — Persistencia en JSON
Section titled “4. src/storage.ts — Persistencia en JSON”Maneja la lectura y escritura del archivo JSON con manejo de errores tipado.
import fs from "node:fs";import path from "node:path";import { Task } from "./types.js";
const TASKS_FILE = path.join(process.cwd(), "tareas.json");
/** * Lee las tareas desde el archivo JSON. * Retorna un array vacío si el archivo no existe. * Usa type narrowing para validar la estructura. */export function leerTareas(): Task[] { try { if (!fs.existsSync(TASKS_FILE)) { return []; }
const data = fs.readFileSync(TASKS_FILE, "utf-8"); const parsed = JSON.parse(data);
// Type guard: verificamos que sea un array if (!Array.isArray(parsed)) { console.error("⚠️ Archivo de tareas corrupto. Se reiniciará."); return []; }
// Rehidratar fechas (vienen como string desde JSON) return parsed.map((item: Record<string, unknown>) => ({ ...item, creadaEn: new Date(item.creadaEn as string), actualizadaEn: new Date(item.actualizadaEn as string), })) as Task[]; } catch (error) { if (error instanceof SyntaxError) { console.error("⚠️ Error de sintaxis en el archivo JSON."); return []; } throw error; }}
/** * Guarda las tareas en el archivo JSON. */export function guardarTareas(tareas: Task[]): void { fs.writeFileSync(TASKS_FILE, JSON.stringify(tareas, null, 2), "utf-8");}5. src/task.ts — Lógica de negocio
Section titled “5. src/task.ts — Lógica de negocio”Clase con métodos para gestionar tareas usando genéricos y tipos.
import { Task, TaskStatus, CreateTaskInput, UpdateTaskInput, TaskError } from "./types.js";import { leerTareas, guardarTareas } from "./storage.js";import crypto from "node:crypto";
/** * Clase que gestiona las operaciones CRUD de tareas. * Usa el patrón Repository con tipos genéricos latentes. */export class TaskManager { private tareas: Task[] = [];
constructor() { this.tareas = leerTareas(); }
/** * Obtiene todas las tareas, opcionalmente filtradas por estado. */ listar(filtro?: TaskStatus): Task[] { if (filtro) { return this.tareas.filter((t) => t.estado === filtro); } return [...this.tareas]; }
/** * Agrega una nueva tarea. */ agregar(input: CreateTaskInput): Task { const nueva: Task = { id: crypto.randomUUID(), titulo: input.titulo, descripcion: input.descripcion ?? "", estado: TaskStatus.PENDIENTE, creadaEn: new Date(), actualizadaEn: new Date(), };
this.tareas.push(nueva); guardarTareas(this.tareas); return nueva; }
/** * Busca una tarea por ID. Retorna null si no existe. * Ejemplo de tipo de retorno con unión. */ buscarPorId(id: string): Task | null { return this.tareas.find((t) => t.id === id) ?? null; }
/** * Actualiza una tarea existente. * Lanza un error tipado si no se encuentra. */ actualizar(input: UpdateTaskInput): Task { const indice = this.tareas.findIndex((t) => t.id === input.id);
if (indice === -1) { const error: TaskError = { codigo: "NO_ENCONTRADA", mensaje: `Tarea con id "${input.id}" no encontrada.`, }; throw error; }
this.tareas[indice] = { ...this.tareas[indice], ...input, actualizadaEn: new Date(), };
guardarTareas(this.tareas); return this.tareas[indice]; }
/** * Marca una tarea como completada. */ completar(id: string): Task { return this.actualizar({ id, estado: TaskStatus.COMPLETADA }); }
/** * Elimina una tarea por ID. */ eliminar(id: string): boolean { const indice = this.tareas.findIndex((t) => t.id === id);
if (indice === -1) { const error: TaskError = { codigo: "NO_ENCONTRADA", mensaje: `Tarea con id "${id}" no encontrada.`, }; throw error; }
this.tareas.splice(indice, 1); guardarTareas(this.tareas); return true; }
/** * Cuenta las tareas por estado. Usa genéricos con Record. */ contarPorEstado(): Record<TaskStatus, number> { const conteo: Record<TaskStatus, number> = { [TaskStatus.PENDIENTE]: 0, [TaskStatus.EN_PROGRESO]: 0, [TaskStatus.COMPLETADA]: 0, };
for (const tarea of this.tareas) { conteo[tarea.estado]++; }
return conteo; }}
/** * Función helper para mostrar una tarea formateada. * Usa typeof para tipos derivados. */export function formatearTarea(tarea: Task): string { const estadoIcono: Record<TaskStatus, string> = { [TaskStatus.PENDIENTE]: "⏳", [TaskStatus.EN_PROGRESO]: "🔄", [TaskStatus.COMPLETADA]: "✅", };
return `${estadoIcono[tarea.estado]} [${tarea.id.slice(0, 8)}] ${tarea.titulo}${ tarea.descripcion ? `\n 📝 ${tarea.descripcion}` : "" }`;}6. src/index.ts — Entry point con argumentos CLI
Section titled “6. src/index.ts — Entry point con argumentos CLI”Procesa process.argv, construye la acción usando discriminated union, y ejecuta con type narrowing.
import { TaskManager, formatearTarea } from "./task.js";import { TaskStatus, CliAction, TaskError } from "./types.js";import process from "node:process";
function main(): void { const args = process.argv.slice(2); // Ignoramos node y el nombre del script const manager = new TaskManager();
// Construir la acción usando el discriminated union let accion: CliAction;
if (args.length === 0 || args[0] === "ayuda" || args[0] === "--help" || args[0] === "-h") { accion = { tipo: "ayuda" }; } else { accion = parsearArgumentos(args); }
// Type narrowing: ejecutar según el tipo de acción switch (accion.tipo) { case "ayuda": mostrarAyuda(); break;
case "listar": { const tareas = manager.listar(accion.filtro); if (tareas.length === 0) { console.log("📭 No hay tareas" + (accion.filtro ? ` con estado "${accion.filtro}"` : "") + "."); } else { console.log(`\n📋 Tareas${accion.filtro ? ` (${accion.filtro})` : ""}:`); console.log("─".repeat(50)); tareas.forEach((t) => console.log(formatearTarea(t))); console.log("─".repeat(50)); console.log(`Total: ${tareas.length}`); } break; }
case "agregar": { const nueva = manager.agregar({ titulo: accion.titulo, descripcion: accion.descripcion, }); console.log("✅ Tarea creada exitosamente:"); console.log(formatearTarea(nueva)); break; }
case "completar": { try { const completada = manager.completar(accion.id); console.log("✅ Tarea marcada como completada:"); console.log(formatearTarea(completada)); } catch (error) { manejarError(error); } break; }
case "eliminar": { try { manager.eliminar(accion.id); console.log(`🗑️ Tarea "${accion.id}" eliminada.`); } catch (error) { manejarError(error); } break; }
default: { // Exhaustiveness check con never const _exhaustivo: never = accion; console.log("Acción desconocida."); } }}
/** * Parsea los argumentos de línea de comandos y construye una CliAction. */function parsearArgumentos(args: string[]): CliAction { const comando = args[0].toLowerCase();
switch (comando) { case "listar": case "ls": { const filtroStr = args[1]?.toLowerCase(); if (!filtroStr) { return { tipo: "listar" }; }
// Type narrowing con Object.values para validar el filtro const filtro = Object.values(TaskStatus).find((s) => s === filtroStr); if (filtro) { return { tipo: "listar", filtro }; }
console.error(`⚠️ Estado inválido: "${filtroStr}". Usa: ${Object.values(TaskStatus).join(", ")}`); return { tipo: "ayuda" }; }
case "agregar": case "add": case "nueva": { const titulo = args.slice(1).join(" "); if (!titulo) { console.error("⚠️ Debes proporcionar un título para la tarea."); return { tipo: "ayuda" }; } return { tipo: "agregar", titulo }; }
case "completar": case "done": { const id = args[1]; if (!id) { console.error("⚠️ Debes proporcionar el ID de la tarea."); return { tipo: "ayuda" }; } return { tipo: "completar", id }; }
case "eliminar": case "rm": case "delete": { const idEliminar = args[1]; if (!idEliminar) { console.error("⚠️ Debes proporcionar el ID de la tarea."); return { tipo: "ayuda" }; } return { tipo: "eliminar", id: idEliminar }; }
default: console.error(`⚠️ Comando desconocido: "${comando}"`); return { tipo: "ayuda" }; }}
/** * Maneja errores tipados usando type narrowing. */function manejarError(error: unknown): void { // Type guard para errores tipados function esTaskError(e: unknown): e is TaskError { return ( typeof e === "object" && e !== null && "codigo" in e && "mensaje" in e ); }
if (esTaskError(error)) { console.error(`❌ Error [${error.codigo}]: ${error.mensaje}`); } else if (error instanceof Error) { console.error(`❌ Error inesperado: ${error.message}`); } else { console.error("❌ Error desconocido."); }}
/** * Muestra la ayuda del programa. */function mostrarAyuda(): void { console.log(`📚 GESTOR DE TAREAS - CLI═══════════════════════════
Uso: npx tsx src/index.ts <comando> [argumentos]
Comandos disponibles:
listar [estado] Lista todas las tareas (o filtradas por estado) Estados: ${Object.values(TaskStatus).join(", ")}
agregar <título> Agrega una nueva tarea
completar <id> Marca una tarea como completada
eliminar <id> Elimina una tarea
ayuda Muestra esta ayuda
Ejemplos:
npx tsx src/index.ts agregar "Aprender TypeScript" npx tsx src/index.ts listar npx tsx src/index.ts listar completada npx tsx src/index.ts completar a1b2c3d4 npx tsx src/index.ts eliminar a1b2c3d4`);}
// Ejecutar el programamain();7. package.json — Scripts y configuración
Section titled “7. package.json — Scripts y configuración”{ "name": "tareas-cli", "version": "1.0.0", "type": "module", "description": "Gestor de tareas CLI en TypeScript - Proyecto Final del curso", "main": "dist/index.js", "scripts": { "dev": "npx tsx src/index.ts", "build": "tsc", "start": "node dist/index.js", "tareas": "npx tsx src/index.ts" }, "keywords": ["typescript", "cli", "tareas", "todo"], "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.4.0" }}8. README.md — Documentación
Section titled “8. README.md — Documentación”# 📚 Gestor de Tareas CLI
Proyecto final del curso de TypeScript Avanzado.
Una aplicación de línea de comandos para gestionar tareas, construida completamente en TypeScript.
## 🚀 Instalación
```bashgit clone <repo-url>cd tareas-clinpm install# Listar todas las tareasnpm run tareas listar
# Listar tareas por estadonpm run tareas listar pendientenpm run tareas listar completada
# Agregar una tareanpm run tareas agregar "Mi nueva tarea"
# Marcar como completadanpm run tareas completar <id-de-la-tarea>
# Eliminar una tareanpm run tareas eliminar <id-de-la-tarea>
# Ver ayudanpm run tareas ayuda🏗️ Compilación
Section titled “🏗️ Compilación”npm run build # Genera el JS en dist/npm start # Ejecuta la versión compilada🧠 Conceptos aplicados
Section titled “🧠 Conceptos aplicados”| Concepto | Ubicación |
|---|---|
| Interfaces y tipos | types.ts |
| Enums | types.ts (TaskStatus) |
| Genéricos | task.ts (implícito) |
| Union types + discriminated unions | types.ts (CliAction) |
| Type narrowing | index.ts (switch), storage.ts (isArray, type guard) |
| Type predicates | index.ts (esTaskError) |
| Clases con modificadores | task.ts (TaskManager) |
| Manejo de archivos con fs | storage.ts |
| Módulos ES6 | Todos los archivos |
| never para exhaustiveness | index.ts (default del switch) |
| Process.argv | index.ts |
| Config tsconfig | tsconfig.json |
---
## ▶️ Cómo ejecutar el proyecto
```bash# 1. Compilarnpm run build
# 2. Ejecutar comandosnode dist/index.js agregar "Mi primera tarea"node dist/index.js listarnode dist/index.js completar <id>
# O en modo desarrollo con tsx (sin compilar)npx tsx src/index.ts agregar "Tarea de prueba"npx tsx src/index.ts listar pendiente🧪 Verificación del aprendizaje
Section titled “🧪 Verificación del aprendizaje”Para comprobar que has entendido el proyecto, intenta:
- Agregar un nuevo comando
editar <id>que permita cambiar el título - Agregar un comando
progreso <id>que cambie el estado aen_progreso - Agregar colores a la salida con la librería
chalk - Migrar los datos a SQLite con
better-sqlite3
🎉 ¡Felicidades!
Section titled “🎉 ¡Felicidades!”Has completado el curso de TypeScript Avanzado. Este proyecto final demuestra tu capacidad para:
- Diseñar sistemas tipados complejos
- Usar discriminated unions y type narrowing para flujos de control seguros
- Implementar persistencia con manejo de errores
- Crear aplicaciones CLI funcionales
- Organizar un proyecto TypeScript modular y profesional
¡El mundo del desarrollo tipado te espera! 🚀
---
## Resumen de la lección
📝 **Hoy has construido:**
- Una CLI app funcional con TypeScript desde cero- Tipos avanzados: interfaces, enums, discriminated unions, type predicates- Type narrowing con switch/case para acciones tipadas- Persistencia en archivos JSON con validación- Estructura modular con barrel files implícitos- Manejo de errores tipado con `unknown` y type guards- Exhaustiveness check con `never`- Compilación y configuración profesional con `tsconfig.json`