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:
// 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:
// 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:
// 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:
// 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:
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:
// 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:
// 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:
// 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.
Paso 2: Una sola fuente de verdad e inmutabilidad tipada
- El tipo Rol es un union literal "Daño" | "Tanque" | "Apoyo": el compilador rechaza cualquier otro valor.
- HeroeEnriquecido extiende Heroe (extends) en vez de duplicar sus campos.
- AgrupacionPorRol usa Record<Rol, HeroeEnriquecido[]> para restringir las claves al union.
- Los parámetros del motor usan readonly donde la función no muta la entrada.
- filtrarPorRol acepta Rol | "todos" como segundo parámetro: "Healer" da error en compilación.
- rankearPorWinrate crea una copia antes de ordenar: no muta el array original.
Paso 3: Validación en la frontera con Zod
- El schema Zod (HeroeSchema) es la única fuente de verdad: el tipo Heroe se deriva con z.infer<typeof HeroeSchema>.
- z.enum restringe el rol tanto en runtime como en el tipo derivado.
- Los datos crudos entran como unknown[]: ningún código accede a sus propiedades sin pasar primero por safeParse.
- validarHeroes procesa el array crudo, descarta los corruptos reportando qué campo falló, y devuelve solo los Heroe[] válidos.
- Los tres datos corruptos del test (partidas como string, rol inválido, campo ausente) se reportan en consola sin lanzar excepción ni detener la app.
- filtrarPorWinrateMinimo(heroes, umbral) devuelve solo los héroes cuyo winrate es mayor o igual al umbral: permite mostrar, por ejemplo, "solo los que ganan más del 55%".
- Cero any, cero as en el código de producción.
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.
// ── dominio.ts ──────────────────────────────────────────────────────────────
// dominio.ts — SOLUCIÓN MEJOR
// El rol ya no es cualquier string: es un union literal.
// HeroeEnriquecido extiende Heroe para no duplicar campos.
// AgrupacionPorRol usa Rol como clave: solo acepta los tres valores válidos.
// Los tres roles posibles como literales: el compilador rechaza cualquier otro valor.
export type Rol = "Daño" | "Tanque" | "Apoyo";
// Un héroe tal como llega del dataset bruto.
export interface Heroe {
nombre: string;
siglas: string;
// rol ahora es Rol, no string genérico: "Healer" o "Mago" ya no compilarían.
rol: Rol;
partidas: number;
victorias: number;
}
// HeroeEnriquecido extiende Heroe: hereda todos sus campos y añade winrate.
// Si Heroe cambia, HeroeEnriquecido lo recoge solo, sin duplicar código.
export interface HeroeEnriquecido extends Heroe {
// winrate: porcentaje de victorias calculado por el motor.
readonly winrate: number;
}
// Record<Rol, HeroeEnriquecido[]> restringe las claves al union Rol.
// Un objeto con la clave "Mago" ya no compilaría.
export type AgrupacionPorRol = Record<Rol, HeroeEnriquecido[]>;
// ── motor.ts ─────────────────────────────────────────────────────────────────
// motor.ts — SOLUCIÓN MEJOR
// Funciones puras con parámetros readonly para señalar que no se mutan.
// El rol del filtro acepta Rol | "todos": el tipo hace imposible pasar "Mago".
// agruparPorRol devuelve Partial<AgrupacionPorRol> porque no todos los roles
// tienen héroes en cualquier dataset (más honesto que AgrupacionPorRol completo).
import type { Heroe, HeroeEnriquecido, Rol, AgrupacionPorRol } from "./dominio";
// ── enriquecer ────────────────────────────────────────────────────────────
// readonly Heroe[]: el parámetro no puede mutarse dentro de la función.
// Retorno: ReadonlyArray<HeroeEnriquecido> — el llamador tampoco puede mutar el resultado.
export function enriquecer(
heroes: readonly Heroe[],
): ReadonlyArray<HeroeEnriquecido> {
// map devuelve un array nuevo; spread copia sin mutar.
return heroes.map(function (h) {
return {
...h,
// winrate con un decimal.
winrate: Math.round((h.victorias / h.partidas) * 1000) / 10,
};
});
}
// ── filtrarPorRol ─────────────────────────────────────────────────────────
// El segundo parámetro es Rol | "todos": solo los valores válidos pueden pasarse.
// Si intentas filtrarPorRol(heroes, "Healer") el compilador lo rechaza aquí.
export function filtrarPorRol(
heroes: readonly HeroeEnriquecido[],
rol: Rol | "todos",
): HeroeEnriquecido[] {
// "todos" devuelve todos sin filtrar.
if (rol === "todos") {
// Array.from convierte el ReadonlyArray en un array mutable para el retorno.
return Array.from(heroes);
}
// filter devuelve un array nuevo sin mutar el original.
return heroes.filter(function (h) {
return h.rol === rol;
});
}
// ── rankearPorWinrate ─────────────────────────────────────────────────────
// Retorna un array nuevo ordenado de mayor a menor winrate.
export function rankearPorWinrate(
heroes: readonly HeroeEnriquecido[],
): HeroeEnriquecido[] {
// [...heroes] crea una copia mutable del ReadonlyArray antes de ordenar.
return [...heroes].sort(function (a, b) {
// orden descendente: mayor winrate primero.
return b.winrate - a.winrate;
});
}
// ── agruparPorRol ─────────────────────────────────────────────────────────
// Partial<AgrupacionPorRol>: honesto porque no sabemos si habrá héroes de cada rol.
// Si el dataset no tiene tanques, la clave "Tanque" sencillamente no existe.
export function agruparPorRol(
heroes: readonly HeroeEnriquecido[],
): Partial<AgrupacionPorRol> {
return heroes.reduce<Partial<AgrupacionPorRol>>(function (acum, h) {
// El tipo de acum[h.rol] es HeroeEnriquecido[] | undefined porque usamos Partial.
// ?? inicializa el array del rol si todavía no existe.
acum[h.rol] = acum[h.rol] ?? [];
// Tras la línea anterior el array existe, pero TypeScript sigue viendo
// HeroeEnriquecido[] | undefined porque no infiere el efecto de la asignación.
// El operador ! (non-null assertion) le dice "confía en mí, no es undefined".
// Es seguro aquí porque acabamos de asignarlo justo arriba.
acum[h.rol]!.push(h);
// Devolvemos el acumulador para la siguiente vuelta.
return acum;
}, {});
}
// Exporta AgrupacionPorRol para que index.ts pueda anotar la variable.
export type { AgrupacionPorRol } from "./dominio";
// ── index.ts ─────────────────────────────────────────────────────────────────
// index.ts — SOLUCIÓN MEJOR
// Datos anotados con Heroe[], rol restringido al union Rol.
// El compilador rechaza roles inválidos en el dataset y en las llamadas al motor.
import type { Heroe, HeroeEnriquecido, Rol } from "./dominio";
import { enriquecer, filtrarPorRol, rankearPorWinrate, agruparPorRol } from "./motor";
import type { AgrupacionPorRol } from "./motor";
// ── Datos de prueba ────────────────────────────────────────────────────────
// rol está anotado en Heroe como Rol, así que "Mago" aquí daría error de compilación.
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 tipo de heroes es ReadonlyArray<HeroeEnriquecido>: el compilador impide
// que cualquier código posterior mute el array.
const heroes: ReadonlyArray<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 ────────────────────────────────────────────────────────
// El segundo argumento debe ser Rol | "todos": cualquier otro valor da error.
const rolFiltrado: Rol = "Daño";
console.log("\n=== Solo " + rolFiltrado + " ===");
filtrarPorRol(heroes, rolFiltrado).forEach(function (h) {
console.log(h.nombre + " — " + h.winrate + "%");
});
// ── Agrupar por rol ────────────────────────────────────────────────────────
console.log("\n=== Por rol ===");
const agrupados: Partial<AgrupacionPorRol> = agruparPorRol(heroes);
// Object.keys devuelve string[]; casteamos a Rol para acceder al tipo correcto.
(Object.keys(agrupados) as Rol[]).forEach(function (rol) {
// agrupados[rol] puede ser undefined (Partial), así que usamos ?? [] para evitar el error.
const lista = agrupados[rol] ?? [];
const nombres = lista.map(function (h) { return h.nombre; }).join(", ");
console.log(rol + ": " + nombres);
}); Por qué es mejor que el anterior
- Rol es un union literal: el compilador rechaza "Healer" en tiempo de compilación, no en runtime.
- HeroeEnriquecido extiende Heroe: un solo campo por definir. Si Heroe añade un campo, HeroeEnriquecido lo hereda solo.
- Record<Rol, HeroeEnriquecido[]> restringe las claves al union: { "Mago": [...] } ya no compila.
- readonly en los parámetros del motor: el compilador impide que las funciones llamen a push, splice o sort en el array recibido.
- filtrarPorRol acepta Rol | "todos" como segundo parámetro: "Healer" daría error aquí, no en runtime.
// ── dominio.ts ──────────────────────────────────────────────────────────────
// dominio.ts — SOLUCIÓN EXCELENTE
// Una sola fuente de verdad: el schema Zod define la forma del dato
// y el tipo TypeScript se deriva de él con z.infer.
// Si el schema cambia, el tipo cambia solo: cero sincronización manual.
import { z } from "zod";
// ── Schema del héroe bruto (tal como llega de la API) ─────────────────────
// z.enum restringe el rol a los tres valores válidos, igual que el union literal
// del tier "mejor", pero ahora es también una validación en runtime.
// Si la API devuelve "Soporte" en lugar de "Apoyo", safeParse lo detecta.
export const RolSchema = z.enum(["Daño", "Tanque", "Apoyo"]);
// El schema completo del héroe bruto.
export const HeroeSchema = z.object({
// nombre: cualquier string no vacío.
nombre: z.string().min(1),
// siglas: exactamente dos letras mayúsculas (o punto más letra, como D.Va).
siglas: z.string().min(2).max(3),
// rol debe ser uno de los tres valores del enum.
rol: RolSchema,
// partidas y victorias: enteros positivos.
partidas: z.number().int().positive(),
victorias: z.number().int().min(0),
});
// El tipo TypeScript del héroe bruto SE DERIVA del schema: una sola fuente de verdad.
// Si añades un campo al schema, el tipo lo recoge solo sin tocar nada más.
export type Heroe = z.infer<typeof HeroeSchema>;
// El tipo del rol se deriva del enum del schema.
export type Rol = z.infer<typeof RolSchema>;
// HeroeEnriquecido extiende el tipo inferido y añade winrate como readonly.
export interface HeroeEnriquecido extends Heroe {
readonly winrate: number;
}
// Record<Rol, ...> restringe las claves al enum: cero valores inesperados.
export type AgrupacionPorRol = Record<Rol, HeroeEnriquecido[]>;
// ── motor.ts ─────────────────────────────────────────────────────────────────
// motor.ts — SOLUCIÓN EXCELENTE
// Validación en la frontera: los datos entran como unknown y solo pasan al motor
// una vez que el schema los ha aprobado. Los corruptos se reportan y se descartan.
// Cero any, cero as, cero estados inválidos posibles.
import {
HeroeSchema,
type Heroe,
type HeroeEnriquecido,
type Rol,
type AgrupacionPorRol,
} from "./dominio";
// ── validarHeroes ─────────────────────────────────────────────────────────
// Recibe datos crudos (unknown[]) y devuelve solo los que pasan el schema.
// Los que fallan se reportan en consola con el campo exacto que falló.
// Nunca lanza excepción: safeParse devuelve { success, data | error }.
export function validarHeroes(datosCrudos: unknown[]): Heroe[] {
const validos: Heroe[] = [];
datosCrudos.forEach(function (dato, indice) {
const resultado = HeroeSchema.safeParse(dato);
if (resultado.success) {
// El dato cumple el schema: es seguro añadirlo.
validos.push(resultado.data);
} else {
// El dato está corrupto: extraemos los issues para un mensaje útil.
const issues = resultado.error.issues
.map(function (issue) {
// path indica qué campo falló; si está vacío, el problema es la raíz.
const campo = issue.path.length > 0 ? String(issue.path[0]) : "raíz";
return campo + ": " + issue.message;
})
.join("; ");
// Reportamos sin romper la app: el héroe corrupto no entra al motor.
console.log("Heroe [" + indice + "] descartado — " + issues);
}
});
return validos;
}
// ── enriquecer ────────────────────────────────────────────────────────────
// Recibe héroes YA VALIDADOS (Heroe[], no unknown[]).
// readonly en el parámetro: la función declara que no va a mutar la entrada.
export function enriquecer(
heroes: readonly Heroe[],
): ReadonlyArray<HeroeEnriquecido> {
// map devuelve un array nuevo; spread copia sin mutar el original.
return heroes.map(function (h) {
return {
...h,
// winrate con un decimal.
winrate: Math.round((h.victorias / h.partidas) * 1000) / 10,
};
});
}
// ── filtrarPorRol ─────────────────────────────────────────────────────────
// El segundo parámetro es Rol | "todos": el tipo hace imposible un valor inválido.
export function filtrarPorRol(
heroes: readonly HeroeEnriquecido[],
rol: Rol | "todos",
): HeroeEnriquecido[] {
// "todos" devuelve todos sin filtrar.
if (rol === "todos") {
return Array.from(heroes);
}
// filter devuelve un array nuevo sin mutar la entrada.
return heroes.filter(function (h) {
return h.rol === rol;
});
}
// ── rankearPorWinrate ─────────────────────────────────────────────────────
// Devuelve el array ordenado de mayor a menor winrate.
export function rankearPorWinrate(
heroes: readonly HeroeEnriquecido[],
): HeroeEnriquecido[] {
// Spread crea una copia mutable del ReadonlyArray antes de ordenar.
return [...heroes].sort(function (a, b) {
// orden descendente: el mayor winrate primero.
return b.winrate - a.winrate;
});
}
// ── agruparPorRol ─────────────────────────────────────────────────────────
// Devuelve Partial<AgrupacionPorRol>: honesto porque no todos los roles
// tienen héroes garantizados en cualquier dataset.
export function agruparPorRol(
heroes: readonly HeroeEnriquecido[],
): Partial<AgrupacionPorRol> {
return heroes.reduce<Partial<AgrupacionPorRol>>(function (acum, h) {
// ?? inicializa el array del rol si todavía no existe.
acum[h.rol] = acum[h.rol] ?? [];
// Tras la línea anterior el array existe, pero TypeScript sigue viendo
// HeroeEnriquecido[] | undefined porque no infiere el efecto de la asignación.
// El operador ! (non-null assertion) le dice "confía en mí, no es undefined".
// Es seguro aquí porque acabamos de asignarlo justo arriba.
acum[h.rol]!.push(h);
// Devolvemos el acumulador para la siguiente vuelta.
return acum;
}, {});
}
// Reexportamos los tipos que index.ts necesita para no multiplicar imports.
export type { AgrupacionPorRol, Rol };
// ── filtrarPorWinrateMinimo ────────────────────────────────────────────────
// Función extra del tier excelente: devuelve solo los héroes que superan un umbral.
// Útil para "mostrar solo los que ganan más del 55% de las partidas".
export function filtrarPorWinrateMinimo(
heroes: readonly HeroeEnriquecido[],
minimo: number,
): HeroeEnriquecido[] {
// filter devuelve un array nuevo sin mutar la entrada.
return heroes.filter(function (h) {
return h.winrate >= minimo;
});
}
// Exportamos el schema para poder usarlo desde index.ts si hiciera falta.
export { HeroeSchema };
// ── index.ts ─────────────────────────────────────────────────────────────────
// index.ts — SOLUCIÓN EXCELENTE
// Los datos entran como unknown[]: simulamos que vienen de una API y no los controlamos.
// Solo pasan al motor los que superan la validación de Zod.
// Los corruptos se reportan sin romper la app. Cero any, cero as.
import type { HeroeEnriquecido, Rol } from "./dominio";
import {
validarHeroes,
enriquecer,
filtrarPorRol,
rankearPorWinrate,
agruparPorRol,
filtrarPorWinrateMinimo,
type AgrupacionPorRol,
} from "./motor";
// ── Datos crudos de la "API" ───────────────────────────────────────────────
// Tipo unknown[]: no asumimos nada sobre la estructura. Cada elemento pasa por el schema.
// En la vida real esto sería: const datosCrudos: unknown = await res.json();
const datosCrudos: unknown[] = [
// Héroes válidos.
{ 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 },
// Dato corrupto 1: partidas es un string en vez de number.
{ nombre: "Moira", siglas: "MO", rol: "Apoyo", partidas: "noventa", victorias: 52 },
// Dato corrupto 2: rol no pertenece al enum válido.
{ nombre: "Orisa", siglas: "OR", rol: "Soporte", partidas: 75, victorias: 38 },
// Dato corrupto 3: falta el campo victorias.
{ nombre: "Hanzo", siglas: "HA", rol: "Daño", partidas: 110 },
];
// ── Frontera de validación ────────────────────────────────────────────────
// validarHeroes procesa unknown[] y devuelve solo los Heroe[] que pasan el schema.
// Los tres corruptos se reportan en consola y no entran al motor.
const heroesValidos = validarHeroes(datosCrudos);
console.log("\nHeroes validos: " + heroesValidos.length + " de " + datosCrudos.length);
// ── Enriquecer una sola vez ────────────────────────────────────────────────
// A partir de aquí, heroes es ReadonlyArray<HeroeEnriquecido>: inmutable y tipado.
const heroes: ReadonlyArray<HeroeEnriquecido> = enriquecer(heroesValidos);
// ── Ranking completo por winrate ───────────────────────────────────────────
console.log("\n=== Ranking por winrate ===");
rankearPorWinrate(heroes).forEach(function (h) {
console.log(h.nombre + " — " + h.winrate + "%");
});
// ── Solo los héroes de Daño ────────────────────────────────────────────────
const rolFiltrado: Rol = "Daño";
console.log("\n=== Solo " + rolFiltrado + " ===");
filtrarPorRol(heroes, rolFiltrado).forEach(function (h) {
console.log(h.nombre + " — " + h.winrate + "%");
});
// ── Los que superan el 55% de winrate ─────────────────────────────────────
console.log("\n=== Winrate >= 55% ===");
filtrarPorWinrateMinimo(heroes, 55).forEach(function (h) {
console.log(h.nombre + " (" + h.rol + ") — " + h.winrate + "%");
});
// ── Agrupados por rol ──────────────────────────────────────────────────────
console.log("\n=== Por rol ===");
const agrupados: Partial<AgrupacionPorRol> = agruparPorRol(heroes);
(Object.keys(agrupados) as Rol[]).forEach(function (rol) {
// ?? [] garantiza que nunca accedemos a undefined aunque el tipo sea Partial.
const lista = agrupados[rol] ?? [];
const nombres = lista.map(function (h) { return h.nombre; }).join(", ");
console.log(rol + ": " + nombres);
}); Por qué es mejor que el anterior
- Lo que cambia respecto al tier "mejor": el tipo Heroe deja de ser una interface escrita a mano. Ahora se deriva del schema Zod con z.infer, de modo que hay una sola fuente de verdad: si añades un campo al schema, el tipo lo recoge solo sin que tengas que actualizar nada más.
- El schema Zod es la única fuente de verdad: type Heroe = z.infer<typeof HeroeSchema>. Si cambias el schema, el tipo se actualiza solo.
- z.enum restringe el rol tanto en runtime (Zod lo rechaza si llega "Soporte") como en el tipo (TypeScript lo rechaza si intentas asignar "Healer").
- Los datos entran como unknown[]: el compilador no te deja leer dato.nombre antes de pasar por safeParse. Es imposible saltarse la validación.
- Los tres datos corruptos se reportan en consola campo a campo (partidas: Expected number; rol: Invalid enum value; victorias: Required) y la app sigue procesando los ocho válidos.
- Cero any, cero as en el código de producción: el sistema de tipos hace imposible un estado inválido.