learning-front

Nivel 5 · TypeScript: JavaScript con red de seguridad

Tipar fetch y validar con Zod

Tipar lo que devuelve una API y validarlo en tiempo de ejecución con Zod, para que un dato inesperado no reviente la app.

A estas alturas del curso has visto tipos que te ayudan mientras escribes el código. Pero hay una brecha que TypeScript no puede cerrar por sí solo: lo que llega en runtime desde fuera de la app — una respuesta de una API, un valor de localStorage, los parámetros de una URL. TypeScript no puede mirar dentro de esos datos mientras compila porque no existen todavía.

Este capítulo cierra esa brecha: primero vemos por qué el tipo puede mentir, luego cómo Zod valida los datos en runtime para que el tipo y la realidad siempre coincidan.

El problema: el tipo miente en runtime#

Cuando haces fetch a una API y llamas a res.json(), lo que obtienes es un valor cuya forma real no conoces hasta que la app corre. La forma más habitual de tiparlo es una type assertion con as (que ya viste en el capítulo de narrowing para estrechar tipos; aquí lo usamos con el mismo mecanismo pero para tipar la respuesta de fetch):

typescript
// fetch devuelve Promise<Response>; res.json() devuelve Promise<any>.
// La type assertion promete a TypeScript que lo que llegue será Heroe[].
const res = await fetch("/api/heroes");
const heroes = (await res.json()) as Heroe[];

Una type assertion (as) es una promesa que haces tú al compilador. TypeScript la acepta sin comprobarlo: no va a mirar dentro del JSON que llegue por la red. Si la API devuelve otra cosa —porque la ha cambiado el backend, porque hay un campo nuevo, porque hubo un error del servidor— en runtime el código explota o produce resultados incorrectos, y TypeScript no te habrá avisado de nada.

typescript
// El compilador no ve ningún problema con esto.
// Pero si partidas llega como string desde la API, en runtime el winrate es NaN.
const heroe = datosDeRed as Heroe;
// Si partidas llegó como string, esto es NaN y nadie te avisó.
console.log(heroe.victorias / heroe.partidas);

La demo de abajo ejecuta los dos casos: uno con datos correctos (el winrate sale bien) y otro con datos corruptos (partidas llega como string). Mira la consola: TypeScript no protestó en ningún momento, pero el resultado del caso corrupto es NaN. Este es exactamente el problema que resuelve la validación en frontera: comprobar los datos reales en el momento en que cruzan el límite entre el exterior y tu app.

Por qué unknown es lo honesto#

En el capítulo 1 viste que unknown acepta cualquier valor pero te obliga a comprobar qué es antes de usarlo. Eso es exactamente lo que necesitamos aquí: tratar lo que llega de fuera como unknown hasta que lo validemos:

typescript
// Honesto: lo que viene de res.json() es unknown hasta que lo comprobemos.
const datos: unknown = await res.json();
// Ahora TypeScript no te deja acceder a datos.nombre ni hacer nada con él
// hasta que hayas comprobado que realmente tiene esa forma.

El problema es que comprobar a mano —con typeof, in, narrowing manual— se vuelve tedioso para objetos con varios campos. Para eso existe Zod.

Zod: validar la forma en runtime#

Zod es una librería que te permite definir un schema —una descripción de la forma esperada— y usarlo para validar datos reales. Si los datos cumplen el schema, obtienes el valor con el tipo correcto. Si no, obtienes un error detallado.

Un schema en Zod se construye componiendo validadores:

typescript
import { z } from "zod";

// z.object describe un objeto con campos tipados.
const HeroeSchema = z.object({
  // Cada campo usa su validador: z.string(), z.number(), z.boolean()...
  nombre: z.string(),
  rol: z.string(),
  partidas: z.number(),
  victorias: z.number(),
});

Con el schema definido, llamas a .parse(datos) para validar. Si los datos cumplen la forma, parse devuelve el valor tipado; si no, lanza un error.

safeParse: validar sin lanzar excepciones#

En una app real raramente quieres que la validación lance una excepción: prefieres manejar el error de forma controlada. Para eso existe safeParse, que devuelve siempre un objeto con una propiedad success:

typescript
// safeParse nunca lanza: devuelve success true o false.
const resultado = HeroeSchema.safeParse(datosExternos);

if (resultado.success) {
  // success true: data contiene el valor tipado por el schema.
  console.log(resultado.data.nombre);
} else {
  // success false: error.issues describe qué campo falló y por qué.
  console.log(resultado.error.issues[0].message);
}

error.issues es un array de objetos con path (qué campo falló) y message (por qué). Eso hace los errores de integración depurables: sabes exactamente qué campo llegó mal, no solo que “algo falló”.

La demo de abajo valida datos correctos (el log muestra el héroe) y datos corruptos (el log muestra qué campo no pasó la validación). Mira la consola después de ejecutar.

z.infer: el tipo y el schema, una sola fuente de verdad#

Ahora tienes un schema que describe la forma del dato. Pero si además defines una interface a mano, tienes dos fuentes de verdad que pueden desincronizarse: cambias el schema pero olvidas actualizar la interface, o al revés.

z.infer resuelve eso derivando el tipo TypeScript directamente del schema:

typescript
// z.infer extrae el tipo del schema: si el schema cambia, el tipo cambia solo.
type Heroe = z.infer<typeof HeroeSchema>;

// Heroe es equivalente a escribir esto a mano, pero sin hacerlo:
// type Heroe = {
//   nombre: string;
//   rol: string;
//   partidas: number;
//   victorias: number;
// };

A partir de ahí puedes usar Heroe como cualquier tipo de TypeScript en las firmas de tus funciones, y el compilador lo comprueba con la misma precisión de siempre.

Campos opcionales con .optional()#

Zod también permite marcar campos como opcionales. Un campo con .optional() puede estar ausente en el dato real sin que la validación falle, y su tipo derivado refleja eso automáticamente:

typescript
const HeroeSchema = z.object({
  nombre: z.string(),
  partidas: z.number(),
  victorias: z.number(),
  // .optional() indica que el campo puede no venir en el dato externo.
  // En el tipo derivado, apodo pasa a ser: string | undefined
  apodo: z.string().optional(),
});

// El tipo derivado es equivalente a:
// type Heroe = {
//   nombre: string;
//   partidas: number;
//   victorias: number;
//   apodo?: string;  ← generado automáticamente, sin escribirlo a mano
// }
type Heroe = z.infer<typeof HeroeSchema>;

Esto es importante: si tuvieras la interface escrita a mano, tendrías que añadir apodo?: string en dos sitios (schema e interface). Con z.infer solo lo tocas en el schema y el tipo se ajusta solo.

Validar una lista: z.array#

Hasta aquí los schemas validan un objeto. Pero una API normalmente devuelve una lista: /api/heroes da un array de héroes, no uno solo. Para validar una colección, envuelves el schema del elemento en z.array:

typescript
// z.array(HeroeSchema) valida que el dato sea un array Y que CADA elemento
// cumpla HeroeSchema. Si un solo héroe viene corrupto, toda la validación falla.
const HeroesSchema = z.array(HeroeSchema);

// El tipo derivado es Heroe[]: otra vez una sola fuente de verdad.
type Heroes = z.infer<typeof HeroesSchema>;

Si prefieres descartar solo los elementos corruptos en vez de rechazar la lista entera, validas cada elemento por separado con un safeParse dentro de un bucle y te quedas con los que pasan. Ese patrón “filtra los válidos” es justo el del ejercicio de este capítulo.

El patrón completo: de fetch a dato validado#

En una app real, el flujo es siempre el mismo:

typescript
// 1. Hacemos la petición a un endpoint que devuelve una LISTA de héroes.
const res = await fetch("/api/heroes");
// 2. Lo que llega es unknown: no nos fiamos de la red.
const datosCrudos: unknown = await res.json();

// 3. Validamos la lista entera con safeParse: sin lanzar, sin as.
const resultado = HeroesSchema.safeParse(datosCrudos);

if (!resultado.success) {
  // 4a. El dato no cumple la forma esperada: logueamos y salimos.
  console.error("Respuesta inesperada de la API:", resultado.error.issues);
  return;
}

// 4b. El dato es válido: resultado.data está tipado como Heroe[] (z.infer<typeof HeroesSchema>).
// Podemos usarlo con total seguridad de tipos, sin ningún as.
const heroes = resultado.data;

Este patrón es la diferencia entre una app que aguanta cambios de API sin sorpresas y una que explota en producción con un Cannot read properties of undefined.

Zod no reemplaza a TypeScript: los dos trabajan juntos. TypeScript te protege en tiempo de compilación (mientras escribes); Zod te protege en tiempo de ejecución (cuando los datos reales llegan). Juntos, la red de seguridad cubre los dos lados.

Comprueba lo que sabes#

Pregunta 1 de 5

¿Qué hace `as Heroe` en TypeScript?

Tu turno#

El fichero de partida simula datos externos como unknown y los usa directamente: el panel “Problemas” señala el error. Define el schema de Zod, valida los datos con safeParse y maneja los casos de éxito y error. Los héroes válidos deben procesarse; los inválidos no deben romper la app.

Ejercicio · en esta página

Valida la respuesta del API del Team Builder

El fichero simula datos externos como unknown y los usa directamente — el panel "Problemas" marca el error. Define el schema de Zod, valida los datos y maneja los casos de éxito y error.

Paso 1: Que funcione

  • Defines un schema con z.object y los tipos correctos para cada campo.
  • Usas safeParse para validar; manejas el caso success false (al menos un console.log de aviso).
  • Los héroes válidos se procesan; los inválidos no rompen la app.
Ver soluciones
import { z } from "zod";

// Schema básico: describe la forma esperada de un héroe.
// z.object agrupa los validadores de cada campo.
const HeroeSchema = z.object({
  // z.string() valida que el campo sea un texto.
  nombre: z.string(),
  // z.string() para el rol también.
  rol: z.string(),
  // z.number() valida que sea un número.
  partidas: z.number(),
  // z.number() para las victorias.
  victorias: z.number(),
});

// Definimos el tipo a mano, en paralelo al schema.
// (En el tier "mejor" veremos cómo evitar esta duplicación con z.infer.)
interface Heroe {
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
}

// Datos externos simulados: unknown[], como si vinieran de la red.
const datosCrudos: unknown[] = [
  { nombre: "Tracer",    rol: "Daño",   partidas: 120, victorias: 78  },
  { nombre: "Mercy",     rol: "Apoyo",  partidas: 200, victorias: 155 },
  // Dato corrupto: partidas llega como string en vez de number.
  { nombre: "Reinhardt", rol: "Tanque", partidas: "noventa y cinco", victorias: 61 },
];

// Procesamos cada dato: validamos con safeParse antes de usarlo.
datosCrudos.forEach(function(dato) {
  // safeParse no lanza: devuelve success true o false.
  const resultado = HeroeSchema.safeParse(dato);

  if (resultado.success) {
    // Dentro del if, resultado.data está tipado según el schema.
    const heroe: Heroe = resultado.data;
    // Calculamos el winrate: victorias entre partidas, como porcentaje.
    const winrate = (heroe.victorias / heroe.partidas * 100).toFixed(1);
    console.log(heroe.nombre + " (" + heroe.rol + "): " + winrate + "%");
  } else {
    // El dato no cumple el schema: avisamos y seguimos con el siguiente.
    console.log("Dato inválido: no cumple el schema esperado.");
  }
});

Por qué este nivel

  • Define un schema básico y usa safeParse: la app ya no rompe con datos corruptos.
  • El manejo del error es mínimo (un log de aviso), pero cumple: el dato inválido no llega a procesarse.
  • Su límite: no hay narrowing de error.issues (el mensaje de error es genérico) y el tipo del héroe se escribe a mano en paralelo al schema — dos fuentes de verdad que pueden desincronizarse.