Skip to content

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.


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)

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.md

Terminal window
mkdir tareas-cli && cd tareas-cli
npm init -y
npm install typescript @types/node --save-dev
npx tsc --init

{
"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"]
}

Aquí definimos todos los tipos del dominio.

💻 Ejemplo

src/types.ts
/**
* 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.

src/storage.ts
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");
}

Clase con métodos para gestionar tareas usando genéricos y tipos.

src/task.ts
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.

src/index.ts
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 programa
main();

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"
}
}

# 📚 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
```bash
git clone <repo-url>
cd tareas-cli
npm install
Terminal window
# Listar todas las tareas
npm run tareas listar
# Listar tareas por estado
npm run tareas listar pendiente
npm run tareas listar completada
# Agregar una tarea
npm run tareas agregar "Mi nueva tarea"
# Marcar como completada
npm run tareas completar <id-de-la-tarea>
# Eliminar una tarea
npm run tareas eliminar <id-de-la-tarea>
# Ver ayuda
npm run tareas ayuda
Terminal window
npm run build # Genera el JS en dist/
npm start # Ejecuta la versión compilada
ConceptoUbicación
Interfaces y tipostypes.ts
Enumstypes.ts (TaskStatus)
Genéricostask.ts (implícito)
Union types + discriminated unionstypes.ts (CliAction)
Type narrowingindex.ts (switch), storage.ts (isArray, type guard)
Type predicatesindex.ts (esTaskError)
Clases con modificadorestask.ts (TaskManager)
Manejo de archivos con fsstorage.ts
Módulos ES6Todos los archivos
never para exhaustivenessindex.ts (default del switch)
Process.argvindex.ts
Config tsconfigtsconfig.json
---
## ▶️ Cómo ejecutar el proyecto
```bash
# 1. Compilar
npm run build
# 2. Ejecutar comandos
node dist/index.js agregar "Mi primera tarea"
node dist/index.js listar
node 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

Para comprobar que has entendido el proyecto, intenta:

  1. Agregar un nuevo comando editar <id> que permita cambiar el título
  2. Agregar un comando progreso <id> que cambie el estado a en_progreso
  3. Agregar colores a la salida con la librería chalk
  4. Migrar los datos a SQLite con better-sqlite3

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`