En esta página

Proyecto final: Sistema de gestión de tareas

25 min lectura TextoCap. 5 — TypeScript en práctica

Proyecto final: ¡integra todo lo que aprendiste!

¡Felicidades por llegar a la última lección del curso! Este proyecto final integra todos los conceptos vistos a lo largo de las 16 lecciones en una sola aplicación cohesionada: un sistema de gestión de tareas con usuarios, proyectos y estadísticas.

El objetivo no es memorizar cada línea de código, sino ver cómo las piezas encajan: los tipos de la lección 1 con los genéricos de la lección 8, las discriminated unions del narrowing con los utility types, los decoradores con los módulos. TypeScript es un sistema que se refuerza a sí mismo.


Lo que construirás

El sistema incluye:

  • Interfaces para Tarea, Usuario y Proyecto con herencia de EntidadBase.
  • Discriminated union para el estado de una tarea (pendiente, en-progreso, completada, cancelada).
  • Patrón repositorio genérico reutilizable para cualquier entidad.
  • Utility types para DTOs de creación y actualización.
  • Decoradores de logging para los métodos del servicio.
  • Type guards personalizados para filtrar tareas por estado.
  • Template literal types para las claves de estadísticas.
  • Mapped types para los filtros de búsqueda.

Código completo del proyecto

// ════════════════════════════════════════════════════════════
// TIPOS BASE
// ════════════════════════════════════════════════════════════

type ID = string;

function generarId(): ID {
  return Math.random().toString(36).slice(2, 10).toUpperCase();
}

interface EntidadBase {
  readonly id: ID;
  readonly creadaEn: Date;
  actualizadaEn: Date;
}

// ════════════════════════════════════════════════════════════
// DOMINIO: USUARIOS
// ════════════════════════════════════════════════════════════

interface Usuario extends EntidadBase {
  nombre: string;
  email: string;
  rol: "admin" | "miembro" | "observador";
  activo: boolean;
}

type CrearUsuarioDto    = Omit<Usuario, keyof EntidadBase>;
type ActualizarUsuarioDto = Partial<Pick<Usuario, "nombre" | "email" | "rol" | "activo">>;

// ════════════════════════════════════════════════════════════
// DOMINIO: PROYECTOS
// ════════════════════════════════════════════════════════════

interface Proyecto extends EntidadBase {
  nombre: string;
  descripcion: string;
  responsableId: ID;
  miembrosIds: readonly ID[];
  archivado: boolean;
}

type CrearProyectoDto    = Omit<Proyecto, keyof EntidadBase>;
type ActualizarProyectoDto = Partial<Pick<Proyecto, "nombre" | "descripcion" | "archivado">>;

// ════════════════════════════════════════════════════════════
// DOMINIO: TAREAS — discriminated union para estado
// ════════════════════════════════════════════════════════════

type Prioridad = "baja" | "media" | "alta" | "crítica";

type EstadoTarea =
  | { tipo: "pendiente" }
  | { tipo: "en-progreso"; iniciadaEn: Date; asignadaA: ID }
  | { tipo: "completada"; completadaEn: Date; notas?: string }
  | { tipo: "cancelada"; motivoCancelacion: string };

interface Tarea extends EntidadBase {
  titulo: string;
  descripcion: string;
  prioridad: Prioridad;
  etiquetas: readonly string[];
  proyectoId: ID | null;
  estado: EstadoTarea;
}

type CrearTareaDto    = Omit<Tarea, keyof EntidadBase>;
type ActualizarTareaDto = Partial<Pick<Tarea, "titulo" | "descripcion" | "prioridad" | "etiquetas" | "proyectoId">>;

// ════════════════════════════════════════════════════════════
// TYPE GUARDS
// ════════════════════════════════════════════════════════════

function esPendiente(tarea: Tarea): tarea is Tarea & { estado: { tipo: "pendiente" } } {
  return tarea.estado.tipo === "pendiente";
}

function estaEnProgreso(
  tarea: Tarea
): tarea is Tarea & { estado: { tipo: "en-progreso"; iniciadaEn: Date; asignadaA: ID } } {
  return tarea.estado.tipo === "en-progreso";
}

function estaCompletada(
  tarea: Tarea
): tarea is Tarea & { estado: { tipo: "completada"; completadaEn: Date; notas?: string } } {
  return tarea.estado.tipo === "completada";
}

function estaCancelada(
  tarea: Tarea
): tarea is Tarea & { estado: { tipo: "cancelada"; motivoCancelacion: string } } {
  return tarea.estado.tipo === "cancelada";
}

// ════════════════════════════════════════════════════════════
// REPOSITORIO GENÉRICO
// ════════════════════════════════════════════════════════════

interface Repositorio<T extends EntidadBase, TCrear, TActualizar> {
  obtener(id: ID): T | null;
  listar(): T[];
  crear(datos: TCrear): T;
  actualizar(id: ID, cambios: TActualizar): T | null;
  eliminar(id: ID): boolean;
}

class RepositorioEnMemoria<T extends EntidadBase, TCrear, TActualizar extends Partial<Omit<T, "id" | "creadaEn">>>
  implements Repositorio<T, TCrear, TActualizar> {

  protected store: Map<ID, T> = new Map();

  obtener(id: ID): T | null {
    return this.store.get(id) ?? null;
  }

  listar(): T[] {
    return Array.from(this.store.values());
  }

  crear(datos: TCrear): T {
    const entidad = {
      ...(datos as object),
      id: generarId(),
      creadaEn: new Date(),
      actualizadaEn: new Date(),
    } as T;
    this.store.set(entidad.id, entidad);
    return entidad;
  }

  actualizar(id: ID, cambios: TActualizar): T | null {
    const existente = this.store.get(id);
    if (!existente) return null;
    const actualizada = { ...existente, ...cambios, actualizadaEn: new Date() } as T;
    this.store.set(id, actualizada);
    return actualizada;
  }

  eliminar(id: ID): boolean {
    return this.store.delete(id);
  }
}

// ════════════════════════════════════════════════════════════
// DECORADOR DE LOGGING
// ════════════════════════════════════════════════════════════

function log(target: unknown, context: ClassMethodDecoratorContext): void {
  const nombre = String(context.name);
  context.addInitializer(function (this: Record<string, unknown>) {
    const original = this[nombre] as (...args: unknown[]) => unknown;
    this[nombre] = function (...args: unknown[]) {
      const etiqueta = `[${context.kind}:${nombre}]`;
      console.log(`${etiqueta} llamado`);
      const resultado = original.apply(this, args);
      console.log(`${etiqueta} completado`);
      return resultado;
    };
  });
}

// ════════════════════════════════════════════════════════════
// ESTADÍSTICAS — template literal types + mapped types
// ════════════════════════════════════════════════════════════

type ClaveEstado = EstadoTarea["tipo"];
type ClavesEstadistica = `total_${ClaveEstado}` | "total_tareas" | "porcentaje_completado";

type EstadisticasProyecto = Record<ClavesEstadistica, number>;

// ════════════════════════════════════════════════════════════
// SERVICIOS
// ════════════════════════════════════════════════════════════

class ServicioTareas {
  constructor(
    private readonly repoTareas: Repositorio<Tarea, CrearTareaDto, ActualizarTareaDto>
  ) {}

  @log
  crear(datos: CrearTareaDto): Tarea {
    return this.repoTareas.crear(datos);
  }

  @log
  iniciar(tareaId: ID, usuarioId: ID): Tarea | null {
    const tarea = this.repoTareas.obtener(tareaId);
    if (!tarea || !esPendiente(tarea)) return null;

    return this.repoTareas.actualizar(tareaId, {
      estado: { tipo: "en-progreso", iniciadaEn: new Date(), asignadaA: usuarioId },
      actualizadaEn: new Date(),
    } as ActualizarTareaDto);
  }

  @log
  completar(tareaId: ID, notas?: string): Tarea | null {
    const tarea = this.repoTareas.obtener(tareaId);
    if (!tarea || !estaEnProgreso(tarea)) return null;

    return this.repoTareas.actualizar(tareaId, {
      estado: { tipo: "completada", completadaEn: new Date(), notas },
      actualizadaEn: new Date(),
    } as ActualizarTareaDto);
  }

  @log
  cancelar(tareaId: ID, motivo: string): Tarea | null {
    const tarea = this.repoTareas.obtener(tareaId);
    if (!tarea || estaCancelada(tarea) || estaCompletada(tarea)) return null;

    return this.repoTareas.actualizar(tareaId, {
      estado: { tipo: "cancelada", motivoCancelacion: motivo },
      actualizadaEn: new Date(),
    } as ActualizarTareaDto);
  }

  obtenerPorProyecto(proyectoId: ID): Tarea[] {
    return this.repoTareas.listar().filter((t) => t.proyectoId === proyectoId);
  }

  obtenerPorPrioridad(prioridad: Prioridad): Tarea[] {
    return this.repoTareas.listar().filter((t) => t.prioridad === prioridad);
  }

  calcularEstadisticas(proyectoId?: ID): EstadisticasProyecto {
    const tareas = proyectoId
      ? this.obtenerPorProyecto(proyectoId)
      : this.repoTareas.listar();

    const totalTareas      = tareas.length;
    const totalPendiente   = tareas.filter(esPendiente).length;
    const totalEnProgreso  = tareas.filter(estaEnProgreso).length;
    const totalCompletada  = tareas.filter(estaCompletada).length;
    const totalCancelada   = tareas.filter(estaCancelada).length;
    const porcentajeCompletado = totalTareas > 0
      ? Math.round((totalCompletada / totalTareas) * 100)
      : 0;

    return {
      total_tareas:              totalTareas,
      total_pendiente:           totalPendiente,
      "total_en-progreso":       totalEnProgreso,
      total_completada:          totalCompletada,
      total_cancelada:           totalCancelada,
      porcentaje_completado:     porcentajeCompletado,
    };
  }
}

// ════════════════════════════════════════════════════════════
// DEMO COMPLETA
// ════════════════════════════════════════════════════════════

// Repositorios
const repoUsuarios  = new RepositorioEnMemoria<Usuario, CrearUsuarioDto, ActualizarUsuarioDto>();
const repoProyectos = new RepositorioEnMemoria<Proyecto, CrearProyectoDto, ActualizarProyectoDto>();
const repoTareas    = new RepositorioEnMemoria<Tarea, CrearTareaDto, ActualizarTareaDto>();
const servicioTareas = new ServicioTareas(repoTareas);

// Crear usuarios
const ana = repoUsuarios.crear({
  nombre: "Ana García",
  email: "[email protected]",
  rol: "admin",
  activo: true,
});

const luis = repoUsuarios.crear({
  nombre: "Luis Pérez",
  email: "[email protected]",
  rol: "miembro",
  activo: true,
});

// Crear proyecto
const proyecto = repoProyectos.crear({
  nombre: "Plataforma Web v2",
  descripcion: "Rediseño completo de la plataforma",
  responsableId: ana.id,
  miembrosIds: [ana.id, luis.id],
  archivado: false,
});

// Crear tareas
const t1 = servicioTareas.crear({
  titulo: "Diseñar sistema de autenticación",
  descripcion: "Implementar login con JWT y refresh tokens",
  prioridad: "crítica",
  etiquetas: ["auth", "seguridad", "backend"],
  proyectoId: proyecto.id,
  estado: { tipo: "pendiente" },
});

const t2 = servicioTareas.crear({
  titulo: "Configurar pipeline de CI/CD",
  descripcion: "GitHub Actions para testing y deploy automático",
  prioridad: "alta",
  etiquetas: ["devops", "ci-cd"],
  proyectoId: proyecto.id,
  estado: { tipo: "pendiente" },
});

const t3 = servicioTareas.crear({
  titulo: "Documentar API pública",
  descripcion: "Generar especificación OpenAPI 3.1",
  prioridad: "media",
  etiquetas: ["docs", "api"],
  proyectoId: proyecto.id,
  estado: { tipo: "pendiente" },
});

// Flujo de trabajo
console.log("\n📋 Tareas creadas:");
repoTareas.listar().forEach((t) => console.log(`  - [${t.prioridad}] ${t.titulo}`));

// Iniciar tarea 1
const t1Iniciada = servicioTareas.iniciar(t1.id, luis.id);
console.log(`\n▶ Tarea iniciada: "${t1Iniciada?.titulo}" (asignada a ${luis.nombre})`);

// Completar tarea 1
const t1Completada = servicioTareas.completar(t1.id, "Sistema OAuth2 + JWT implementado con éxito");
if (t1Completada && estaCompletada(t1Completada)) {
  console.log(`\n✅ Completada el ${t1Completada.estado.completadaEn.toLocaleDateString("es-ES")}`);
  console.log(`   Notas: ${t1Completada.estado.notas}`);
}

// Iniciar y cancelar tarea 3
servicioTareas.iniciar(t3.id, ana.id);
servicioTareas.cancelar(t3.id, "Se pospone al siguiente sprint");

// Estadísticas del proyecto
const stats = servicioTareas.calcularEstadisticas(proyecto.id);
console.log("\n📊 Estadísticas del proyecto:");
console.log(`  Total de tareas:       ${stats.total_tareas}`);
console.log(`  Pendientes:            ${stats["total_pendiente"]}`);
console.log(`  En progreso:           ${stats["total_en-progreso"]}`);
console.log(`  Completadas:           ${stats["total_completada"]}`);
console.log(`  Canceladas:            ${stats["total_cancelada"]}`);
console.log(`  Porcentaje completado: ${stats.porcentaje_completado}%`);

// Filtrado por prioridad con type guard implícito
const tareasCriticas = servicioTareas.obtenerPorPrioridad("crítica");
console.log(`\n🔴 Tareas críticas: ${tareasCriticas.length}`);
tareasCriticas.forEach((t) => {
  if (estaCompletada(t)) {
    console.log(`  ✅ ${t.titulo} (completada)`);
  } else if (estaEnProgreso(t)) {
    console.log(`  ▶ ${t.titulo} (en progreso, asignada a ${t.estado.asignadaA})`);
  } else {
    console.log(`  ⬜ ${t.titulo} (${t.estado.tipo})`);
  }
});

Conceptos integrados en este proyecto

Revisa cómo cada concepto del curso aparece en el código:

Lección Concepto Dónde aparece
1-2 Tipos básicos e interfaces ID, EntidadBase, Tarea, Usuario, Proyecto
3 Funciones tipadas generarId(), calcularEstadisticas()
4 Uniones e intersecciones EstadoTarea (discriminated union), Prioridad
5 Tipos avanzados CrearTareaDto, ActualizarTareaDto con Omit/Pick
6 Enums y literales "baja" | "media" | "alta" | "crítica", "admin" | "miembro"
7 Interfaces avanzadas Repositorio<T, TCrear, TActualizar>
8 Genéricos RepositorioEnMemoria<T, TCrear, TActualizar>
9 Genéricos avanzados T extends EntidadBase en el repositorio genérico
10 Narrowing y type guards esPendiente(), estaCompletada(), estaEnProgreso()
11 Utility types Partial, Pick, Omit, Record en los DTOs y estadísticas
12 Template literal types ClavesEstadistica, EstadisticasProyecto
13 Clases y decoradores RepositorioEnMemoria, ServicioTareas, decorador @log
14 Módulos Estructura separada por dominio (usuarios, proyectos, tareas)
15 Migración JS → TS Tipado estricto desde el inicio, sin any

Desafíos para seguir practicando

¿Quieres ir más allá? Extiende el proyecto con estas funcionalidades:

  1. Añade un módulo de comentarios: cada tarea puede tener comentarios con autor y fecha. Usa genéricos para un tipo ComentarioEn<T extends EntidadBase>.
  2. Implementa búsqueda type-safe: una función buscar<T>(entidades: T[], filtro: Partial<T>): T[] que filtre por cualquier campo.
  3. Añade validación con tipos condicionales: un tipo Validado<T> que marque un objeto como validado y evite operar con datos no validados.
  4. Crea un tipo de error de dominio: ErrorDominio<TContexto> con infer para extraer el tipo del contexto automáticamente en el manejador de errores.
  5. Persiste en localStorage: crea un repositorio alternativo RepositorioLocalStorage<T> que serialice y deserialice los datos, respetando la interfaz genérica.

¡Enhorabuena por completar el curso de TypeScript completo! Has pasado de los tipos básicos hasta los mapped types recursivos, los decoradores TC39 y el patrón repositorio genérico. TypeScript ya no es solo un superset de JavaScript para ti: es una herramienta de diseño que te ayuda a modelar el dominio de tu aplicación de forma precisa y segura.

Juega con el código: experimenta y rompe cosas
El mejor aprendizaje viene de experimentar. Intenta añadir una nueva prioridad al tipo Prioridad y observa cómo el compilador te guía a actualizar todos los lugares afectados. Eso es TypeScript en su máxima expresión.
¿Qué sigue después de este curso?
Los siguientes pasos naturales son: aprender el compilador avanzado (tsc --watch, composite projects, project references), explorar zod o valibot para validación en runtime, y estudiar cómo TypeScript se integra con frameworks como Angular, React o NestJS.