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):
// 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.
// 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:
// 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:
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:
// 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:
// 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:
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:
// 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:
// 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.
Paso 2: Que esté pulido
- Derivas el tipo con z.infer: una sola fuente de verdad para schema y tipo.
- Manejas error.issues de forma limpia: muestra qué campo falló y por qué.
- Cero type assertion (as) y cero any en todo el fichero.
Paso 3: Que sea excelente
- El schema incluye el rol como union de literales (z.union + z.literal) para los tres roles válidos.
- Un campo opcional (apodo?) en el schema, con su tipo reflejado automáticamente vía z.infer.
- Los datos corruptos se filtran y reportan campo a campo; los válidos se procesan con cero as, cero any y el tipo derivado del schema.
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.
import { z } from "zod";
// Schema: la única fuente de verdad sobre la forma del héroe.
const HeroeSchema = z.object({
nombre: z.string(),
rol: z.string(),
partidas: z.number(),
victorias: z.number(),
});
// z.infer deriva el tipo TypeScript directamente del schema.
// Si el schema cambia, el tipo cambia solo: no hay que actualizar la interface a mano.
type Heroe = z.infer<typeof HeroeSchema>;
// 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 },
];
// Función que formatea el resumen de un héroe ya validado.
// La firma usa Heroe, el tipo derivado del schema.
function formatearResumen(heroe: Heroe): string {
// victorias y partidas son number: TypeScript lo sabe gracias al schema.
const winrate = (heroe.victorias / heroe.partidas * 100).toFixed(1);
return heroe.nombre + " (" + heroe.rol + "): " + winrate + "%";
}
// Procesamos cada dato validando con safeParse.
datosCrudos.forEach(function(dato) {
const resultado = HeroeSchema.safeParse(dato);
if (resultado.success) {
// resultado.data es de tipo Heroe: podemos pasárselo a formatearResumen.
console.log(formatearResumen(resultado.data));
} else {
// error.issues describe qué campo falló y por qué.
// Mostramos el primer issue: campo y mensaje de error.
const issue = resultado.error.issues[0];
// path es un array con la ruta al campo fallido (p.ej. ["partidas"]).
const campo = issue.path.join(".");
console.log("Dato inválido — campo '" + campo + "': " + issue.message);
}
}); Por qué es mejor que el anterior
- z.infer elimina la duplicación: el tipo Heroe sale del schema, no se escribe a mano. Si cambias el schema, el tipo cambia solo.
- error.issues da el detalle: qué campo falló y por qué, no un mensaje genérico. Así los errores de integración son depurables.
- Cero as y cero any: la red de seguridad queda intacta de punta a punta del flujo.
import { z } from "zod";
// El rol no es cualquier string: solo puede ser uno de estos tres valores.
// z.union con z.literal modela un conjunto cerrado de valores exactos.
// (z.enum([...]) es azúcar para esto mismo, pero union+literal enlaza con lo que
// ya conoces del capítulo de uniones e intersecciones.)
const RolSchema = z.union([
z.literal("Daño"),
z.literal("Tanque"),
z.literal("Apoyo"),
]);
// Schema completo del héroe, con el rol restringido y un campo opcional.
const HeroeSchema = z.object({
// nombre es un string sin restricción adicional.
nombre: z.string(),
// rol debe ser exactamente uno de los tres literales definidos.
rol: RolSchema,
// partidas es un número entero positivo en la realidad, pero el schema básico
// de z.number() ya detecta el caso corrupto (string en vez de number).
partidas: z.number(),
// victorias: número de victorias acumuladas.
victorias: z.number(),
// apodo es opcional: algunos héroes lo tienen, otros no.
// .optional() hace el campo string | undefined en el tipo derivado.
apodo: z.string().optional(),
});
// El tipo Heroe sale del schema: una sola fuente de verdad.
// Si añades o cambias un campo en el schema, el tipo se actualiza solo.
type Heroe = z.infer<typeof HeroeSchema>;
// Datos externos simulados: mezcla de héroes válidos, corruptos y con rol inválido.
const datosCrudos: unknown[] = [
// Válido con apodo.
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78, apodo: "La corredora" },
// Válido sin apodo: el campo opcional puede estar ausente.
{ nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 155 },
// Corrupto: partidas es string en vez de number.
{ nombre: "Reinhardt", rol: "Tanque", partidas: "noventa y cinco", victorias: 61 },
// Corrupto: rol no pertenece al conjunto cerrado de valores válidos.
{ nombre: "Moira", rol: "Soporte", partidas: 88, victorias: 52 },
];
// Función que formatea el resumen de un héroe ya validado.
function formatearResumen(heroe: Heroe): string {
// victorias y partidas son number garantizado por el schema.
const winrate = (heroe.victorias / heroe.partidas * 100).toFixed(1);
// Si el héroe tiene apodo, lo añadimos entre paréntesis.
const sufijo = heroe.apodo ? " — " + heroe.apodo : "";
return heroe.nombre + sufijo + " (" + heroe.rol + "): " + winrate + "%";
}
// Función que formatea todos los issues de un error de validación.
// Así los errores de integración son depurables campo a campo.
function formatearErrores(issues: { path: PropertyKey[]; message: string }[]): string {
return issues
.map(function(issue) {
// path puede ser un array vacío si el error es en la raíz del objeto.
// String() convierte cada parte del path (string, number o symbol) a texto.
const partes = issue.path.map(String);
const campo = partes.length > 0 ? "'" + partes.join(".") + "'" : "raíz";
return campo + ": " + issue.message;
})
.join("; ");
}
// Procesamos cada dato: los válidos se muestran, los inválidos se reportan y descartan.
datosCrudos.forEach(function(dato) {
const resultado = HeroeSchema.safeParse(dato);
if (resultado.success) {
// El dato cumple el schema: resultado.data es de tipo Heroe con garantía.
console.log(formatearResumen(resultado.data));
} else {
// El dato no cumple el schema: reportamos todos los campos que fallaron.
// El índice 0 de issues a menudo ya indica qué campo es; mostramos todos.
console.log("Dato descartado — " + formatearErrores(resultado.error.issues));
}
}); Por qué es mejor que el anterior
- El rol como z.union de literales es más que un detalle estético: un rol que no sea "Daño", "Tanque" o "Apoyo" falla la validación en runtime, igual que fallaría el type-check con una union de literales en TypeScript puro. El schema y el sistema de tipos dicen lo mismo.
- apodo?: el campo opcional en el schema genera automáticamente `string | undefined` en el tipo derivado. No hay que mantener la interface y el schema en paralelo.
- Los datos corruptos se reportan campo a campo y se descartan; los válidos fluyen con el tipo garantizado. Nadie usa `as` para saltarse la red ni `any` para apagarla.