learning-front

Nivel 5 · TypeScript: JavaScript con red de seguridad

Proyecto: tipar el motor del Team Builder

Mega-tarea de cierre: coge el motor de datos de JavaScript (filtrar, rankear y agrupar) y hazlo type-safe de punta a punta, validando la entrada con Zod.


El Nivel 5 ha cubierto tipos básicos, interfaces y types, uniones e intersecciones, narrowing, genéricos, utility types, tsconfig y la combinación de fetch con Zod. Este es el noveno capítulo: no hay concepto nuevo. La pregunta es si puedes usar todo eso junto para resolver un problema real de principio a fin.

Qué construyes#

En el Nivel 2 escribiste un motor de datos del Team Builder en JavaScript: a partir de un array de héroes calculabas el winrate, filtrabas por rol, rankeabas por porcentaje de victorias y agrupabas por categoría. Funcionaba, pero el compilador no te avisaba de nada: un campo mal escrito, un rol inventado o un número que llegara como string se convertían en bugs que solo aparecían al ejecutar.

Ahora revisitas ese motor y lo conviertes en type-safe de punta a punta. El objetivo no es reescribir la lógica (ya funciona), sino dotarla de un sistema de tipos que haga imposibles los errores más comunes antes de que el código llegue a producción.

El proyecto tiene tres ficheros:

  • dominio.ts — el contrato de datos: qué es un héroe, qué roles existen.
  • motor.ts — las funciones puras: enriquecer, filtrar, rankear, agrupar.
  • index.ts — el punto de entrada: datos de prueba, llamadas al motor, salida en consola.

El starter tiene la lógica escrita pero los tipos pendientes. Tu trabajo es añadirlos.

Por qué tiparlo cambia algo real#

En JavaScript, este código no da ningún aviso:

typescript
// JS puro: el compilador no ve el error.
// partidas llega como string de la API.
const winrate = (h.victorias / h.partidas) * 100;
// victorias / "noventa" → NaN → NaN * 100 → NaN.
// La tarjeta muestra "NaN%" y nadie sabe por qué.

Con TypeScript y Zod, el mismo dato corrupto no llega nunca al motor:

typescript
// El schema rechaza partidas: "noventa" porque espera z.number().
// El dato se descarta aquí, con un mensaje que dice exactamente qué campo falló.
// El motor solo ve datos limpios.
const resultado = HeroeSchema.safeParse(dato);
if (!resultado.success) {
  // Reportamos el issue: "partidas: Expected number, received string"
  console.log("Descartado: " + resultado.error.issues[0].message);
}

La diferencia no es que el código sea más largo. Es que el error es visible e inevitable: el compilador y el schema te avisan, en vez de dejar que llegue al usuario como “NaN%”.

El plan de ataque#

1. El dominio primero#

Antes de tocar el motor, define los tipos en dominio.ts. El orden importa: si el motor no sabe qué es un Heroe, no puede anotar sus parámetros.

Para el tier ok, cualquier interface con los campos correctos funciona:

typescript
// Una interface básica: los campos están, el compilador puede trabajar.
export interface Heroe {
  nombre: string;
  siglas: string;
  // string acepta cualquier valor; funciona, pero no cierra el conjunto.
  rol: string;
  partidas: number;
  victorias: number;
}

Para el tier mejor, el rol deja de ser un string abierto:

typescript
// Un union literal restringe los valores válidos.
// El compilador rechaza "Healer" aquí, no en runtime.
export type Rol = "Daño" | "Tanque" | "Apoyo";

// HeroeEnriquecido extiende Heroe: no duplica campos.
// Si Heroe cambia, HeroeEnriquecido lo recoge solo.
export interface HeroeEnriquecido extends Heroe {
  readonly winrate: number;
}

Para el tier excelente, el tipo sale del schema de Zod:

typescript
import { z } from "zod";

// z.enum cierra el conjunto de roles válidos en runtime Y en el tipo.
export const RolSchema = z.enum(["Daño", "Tanque", "Apoyo"]);

// HeroeSchema describe la forma completa del dato.
export const HeroeSchema = z.object({
  nombre: z.string().min(1),
  siglas: z.string().min(2).max(3),
  rol: RolSchema,
  partidas: z.number().int().positive(),
  victorias: z.number().int().min(0),
});

// El tipo se deriva del schema: una sola fuente de verdad.
// Si el schema cambia, el tipo cambia solo.
export type Heroe = z.infer<typeof HeroeSchema>;

2. El motor: anotar sin cambiar la lógica#

Las cuatro funciones ya están escritas. Solo añades tipos en los parámetros y retornos:

typescript
// Antes (JS puro, parámetro implícitamente any):
function enriquecer(heroes) { ... }

// Después (TS, parámetro y retorno anotados):
export function enriquecer(heroes: readonly Heroe[]): ReadonlyArray<HeroeEnriquecido> {
  // La lógica es idéntica; solo los tipos son nuevos.
  return heroes.map(function (h) {
    return { ...h, winrate: Math.round((h.victorias / h.partidas) * 1000) / 10 };
  });
}

El readonly en el parámetro no es solo un convenio: el compilador impide que la función llame a push o splice sobre el array recibido. Declara el contrato de pureza en el tipo.

3. La frontera de validación (tier excelente)#

En el tier excelente, los datos no se anotan como Heroe[] desde el principio. Entran como unknown[] y solo cruzan la frontera después de que Zod los apruebe:

typescript
// Los datos "vienen de la API": no sabemos su forma.
const datosCrudos: unknown[] = await res.json();

// validarHeroes procesa el array, descarta los corruptos y devuelve Heroe[].
// El motor nunca ve un dato sin validar.
const heroesValidos = validarHeroes(datosCrudos);

La función validarHeroes usa safeParse por cada elemento: los que pasan entran, los que fallan se reportan con el campo exacto que causó el problema:

typescript
// safeParse no lanza excepción: devuelve { success, data | error }.
const resultado = HeroeSchema.safeParse(dato);

if (resultado.success) {
  // El tipo de resultado.data es Heroe: TypeScript lo sabe.
  validos.push(resultado.data);
} else {
  // Extraemos el campo que falló del primer issue.
  const campo = resultado.error.issues[0].path[0];
  const mensaje = resultado.error.issues[0].message;
  console.log("Descartado en campo '" + campo + "': " + mensaje);
}

El estado inválido es imposible: o el dato pasa el schema y es Heroe, o no entra.

Comprueba lo que sabes#

Pregunta 1 de 5

En el tier "ok" el rol es de tipo string. ¿Qué problema concreto resuelve cambiarlo a un union literal "Daño" | "Tanque" | "Apoyo"?

Tu turno#

Este proyecto se trabaja en local, en tu editor: con TypeScript real, type-check real y el sistema de tipos que marca los errores mientras escribes.

Abre la carpeta exercises/nivel-5/proyecto-tipar-el-motor-del-team-builder/starter/ dentro del repo que ya tienes clonado. Sigue los TODO en orden: primero dominio.ts, luego motor.ts, luego index.ts. Cuando el editor no marque ningún error, compara con las soluciones.

Si conservas el motor que escribiste en el Nivel 2 (el fichero JavaScript con enriquecer, filtrarPorRol, rankearPorWinrate y agruparPorRol), puedes partir de él en vez del starter: copia la lógica y añade los tipos encima. El resultado debe ser equivalente al starter; la diferencia es solo el punto de partida.

Ejercicio · hazlo en local

Tipar el motor del Team Builder

El motor de datos que escribiste en JavaScript en el Nivel 2 ya funciona: calcula winrate, filtra por rol, rankea y agrupa. Ahora tienes que hacerlo type-safe de punta a punta. Parte del starter: tres ficheros (dominio.ts, motor.ts, index.ts) con la lógica ya escrita pero los tipos sin anotar. Añade los tipos, haz que compile bajo --strict sin any, y en el tier excelente valida la entrada con Zod antes de que los datos toquen el motor.

Paso 1: Que compile y funcione

  • dominio.ts define Heroe, HeroeEnriquecido y AgrupacionPorRol con interface o type.
  • motor.ts anota los parámetros y tipos de retorno de las cuatro funciones: enriquecer, filtrarPorRol, rankearPorWinrate y agruparPorRol.
  • index.ts anota heroesBase con Heroe[] y las variables intermedias con sus tipos.
  • Todo compila bajo --strict sin errores. Cero any sin intención.
  • La lógica produce resultados correctos: el ranking sale de mayor a menor winrate, el filtro devuelve solo los héroes del rol pedido y la agrupación los separa por clave.

Cómo hacerlo en local

Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el index.html en tu navegador. Toda tu solución va en solucion.js.

git clone <repo>
cd exercises/nivel-5/proyecto-tipar-el-motor-del-team-builder
# abre index.html en el navegador y edita solucion.js
Ver soluciones
// ── dominio.ts ──────────────────────────────────────────────────────────────
// dominio.ts — SOLUCIÓN OK
// El contrato de datos: tipos definidos, el motor puede importarlos.
// Qué tiene de mejorable:
//   - El rol es string sin restricción: "Mago", "Healer" o cualquier typo pasarían el compilador.
//   - HeroeEnriquecido duplica los campos de Heroe en vez de extenderlo (si cambia Heroe, hay que
//     acordarse de cambiar HeroeEnriquecido también).
//   - AgrupacionPorRol usa string como clave, así que { "Mago": [...] } también compilaría.

// Un héroe tal como llega del dataset bruto.
export interface Heroe {
  nombre: string;
  siglas: string;
  // rol es cualquier string: funciona, pero no restringe a los valores válidos.
  rol: string;
  partidas: number;
  victorias: number;
}

// Un héroe una vez calculado su winrate.
export interface HeroeEnriquecido {
  nombre: string;
  siglas: string;
  rol: string;
  partidas: number;
  victorias: number;
  // winrate añadido por el motor al enriquecer.
  winrate: number;
}

// El resultado de agrupar héroes por rol: { "Daño": [...], "Tanque": [...], "Apoyo": [...] }
export type AgrupacionPorRol = { [rol: string]: HeroeEnriquecido[] };


// ── motor.ts ─────────────────────────────────────────────────────────────────
// motor.ts — SOLUCIÓN OK
// Las funciones del motor, tipadas de punta a punta.
// Compila bajo strict, sin any.
// Qué tiene de mejorable:
//   - Los tipos de retorno se podrían derivar de una única fuente (z.infer del schema).
//   - Las funciones no están marcadas como puras explícitamente (sin readonly en los parámetros).
//   - agruparPorRol usa un objeto vacío {} con aserción de tipo para inicializar el acumulador.

import type { Heroe, HeroeEnriquecido, AgrupacionPorRol } from "./dominio";

// ── enriquecer ────────────────────────────────────────────────────────────
// Recibe héroes brutos, devuelve un array nuevo con el winrate calculado.
// Heroe[] como parámetro, HeroeEnriquecido[] como retorno.
export function enriquecer(heroes: Heroe[]): HeroeEnriquecido[] {
  // map crea un array nuevo: el original no se muta.
  return heroes.map(function (h) {
    // spread copia todos los campos de h, y añadimos winrate.
    return {
      ...h,
      // winrate con un decimal: Math.round(...* 1000) / 10 es más preciso que toFixed.
      winrate: Math.round((h.victorias / h.partidas) * 1000) / 10,
    };
  });
}

// ── filtrarPorRol ─────────────────────────────────────────────────────────
// Parámetros anotados; retorno HeroeEnriquecido[] (siempre un array, nunca undefined).
export function filtrarPorRol(heroes: HeroeEnriquecido[], rol: string): HeroeEnriquecido[] {
  // "todos" devuelve el array completo.
  if (rol === "todos") {
    return heroes;
  }
  // filter devuelve un array nuevo sin mutar el original.
  return heroes.filter(function (h) {
    return h.rol === rol;
  });
}

// ── rankearPorWinrate ─────────────────────────────────────────────────────
// Devuelve el array ordenado de mayor a menor winrate.
export function rankearPorWinrate(heroes: HeroeEnriquecido[]): HeroeEnriquecido[] {
  // Copia el array antes de ordenar: sort muta en su lugar.
  return [...heroes].sort(function (a, b) {
    // orden descendente: mayor winrate primero.
    return b.winrate - a.winrate;
  });
}

// ── agruparPorRol ─────────────────────────────────────────────────────────
// Devuelve un objeto { Daño: [...], Tanque: [...], Apoyo: [...] }.
export function agruparPorRol(heroes: HeroeEnriquecido[]): AgrupacionPorRol {
  return heroes.reduce(function (acum, h) {
    // Si la clave del rol no existe, la inicializamos con un array vacío.
    if (!acum[h.rol]) {
      acum[h.rol] = [];
    }
    // Añadimos el héroe al array de su rol.
    acum[h.rol].push(h);
    // Devolvemos el acumulador actualizado.
    return acum;
  }, {} as AgrupacionPorRol);
}


// ── index.ts ─────────────────────────────────────────────────────────────────
// index.ts — SOLUCIÓN OK
// Punto de entrada tipado: datos anotados con Heroe[], motor llamado correctamente.
// Compila bajo strict, sin any.

import type { Heroe, HeroeEnriquecido, AgrupacionPorRol } from "./dominio";
import { enriquecer, filtrarPorRol, rankearPorWinrate, agruparPorRol } from "./motor";

// ── Datos de prueba ────────────────────────────────────────────────────────
// El array está anotado con Heroe[]: si añades un campo de más o te olvidas uno,
// el compilador lo detecta antes de ejecutar.
const heroesBase: Heroe[] = [
  { nombre: "Tracer",    siglas: "TR", rol: "Daño",   partidas: 120, victorias: 78  },
  { nombre: "Reinhardt", siglas: "RE", rol: "Tanque", partidas: 90,  victorias: 51  },
  { nombre: "Mercy",     siglas: "ME", rol: "Apoyo",  partidas: 200, victorias: 130 },
  { nombre: "Genji",     siglas: "GE", rol: "Daño",   partidas: 150, victorias: 72  },
  { nombre: "D.Va",      siglas: "DV", rol: "Tanque", partidas: 140, victorias: 84  },
  { nombre: "Ana",       siglas: "AN", rol: "Apoyo",  partidas: 95,  victorias: 57  },
  { nombre: "Zenyatta",  siglas: "ZE", rol: "Apoyo",  partidas: 80,  victorias: 44  },
  { nombre: "Pharah",    siglas: "PH", rol: "Daño",   partidas: 130, victorias: 71  },
];

// ── Enriquecer una sola vez ────────────────────────────────────────────────
// El winrate se calcula aquí; el resto del código solo lo lee.
const heroes: HeroeEnriquecido[] = enriquecer(heroesBase);

// ── Ranking por winrate ────────────────────────────────────────────────────
console.log("=== Ranking por winrate ===");
rankearPorWinrate(heroes).forEach(function (h) {
  console.log(h.nombre + "" + h.winrate + "%");
});

// ── Filtrar por rol ────────────────────────────────────────────────────────
console.log("\n=== Solo Daño ===");
filtrarPorRol(heroes, "Daño").forEach(function (h) {
  console.log(h.nombre + "" + h.winrate + "%");
});

// ── Agrupar por rol ────────────────────────────────────────────────────────
console.log("\n=== Por rol ===");
const agrupados: AgrupacionPorRol = agruparPorRol(heroes);
Object.keys(agrupados).forEach(function (rol) {
  const nombres = agrupados[rol].map(function (h) { return h.nombre; }).join(", ");
  console.log(rol + ": " + nombres);
});

Por qué este nivel

  • Compila bajo strict sin any: los cuatro parámetros y retornos están anotados.
  • Heroe y HeroeEnriquecido son interfaces independientes: si cambias Heroe, tienes que acordarte de actualizar HeroeEnriquecido también — es duplicación.
  • El rol es string: "Healer" o "Soporte" compilarían sin error. El tipo no cierra el conjunto de valores válidos.
  • AgrupacionPorRol usa [rol: string] como clave: acepta cualquier string, no solo los roles del juego.
  • El motor funciona: la lógica de filtrar, rankear y agrupar es correcta y produce los resultados esperados.