Validar la respuesta de fetch con Zod en TypeScript

Cuando pides datos a una API con fetch, TypeScript no puede saber qué te va a responder el servidor: eso solo se sabe al ejecutar. Si le pones un tipo a la respuesta sin comprobarla, el compilador te cree y los errores aparecen lejos, donde menos lo esperas. Zod resuelve las dos cosas a la vez: comprueba los datos al llegar y te da su tipo.

El servidor de prueba

Para no depender de ninguna API externa, montamos un servidor mínimo. En /productos responde lo que esperamos. En /productos-v2 imita algo muy común: alguien cambió la API y ahora el precio llega como texto y uno de los productos trae titulo en vez de nombre.

import { createServer } from "node:http";

// Un servidor de prueba: /productos responde bien; /productos-v2 cambió sin avisar.
const bien = [
  { id: 1, nombre: "Audífonos", precio: 1299.99, etiquetas: ["audio"] },
  { id: 2, nombre: "Webcam", precio: 899, etiquetas: [] },
];
const cambiado = [
  { id: 1, nombre: "Audífonos", precio: "1299.99", etiquetas: ["audio"] },
  { id: 2, titulo: "Webcam", precio: 899 },
];

createServer((req, res) => {
  const datos = req.url === "/productos" ? bien : req.url === "/productos-v2" ? cambiado : null;
  res.writeHead(datos ? 200 : 404, { "Content-Type": "application/json" });
  res.end(JSON.stringify(datos ?? { error: "no existe" }));
}).listen(3001);

Sin validar: el tipo es una promesa que nadie cumple

Este cliente anota que la función devuelve Producto[]. Compila sin un solo aviso:

interface Producto {
  id: number;
  nombre: string;
  precio: number;
}

// res.json() devuelve Promise<any>: la anotación es una promesa que nadie comprueba.
async function productos(ruta: string): Promise<Producto[]> {
  const res = await fetch("http://localhost:3001" + ruta);
  return res.json();
}

for (const ruta of ["/productos", "/productos-v2"]) {
  const lista = await productos(ruta);
  const total = lista.reduce((suma, p) => suma + p.precio, 0);
  console.log(ruta, "total:", total);
  console.log(ruta, "nombres:", lista.map((p) => p.nombre.toUpperCase()).join(", "));
}
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es sin-validar.ts
tsc(sin errores)

La razón: res.json() devuelve Promise<any>, y any se puede asignar a cualquier cosa. Pero al ejecutarlo contra la API cambiada pasa esto:

node sin-validar.ts
salida/productos total: 2198.99
/productos nombres: AUDÍFONOS, WEBCAM
/productos-v2 total: 01299.99899
file:///w/zod/sin-validar.ts:17
  console.log(ruta, "nombres:", lista.map((p) => p.nombre.toUpperCase()).join(", "));
                                                          ^

TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Node.js v24.22.0

Dos fallos, y los dos lejos del origen. El total sale 01299.99899: 0 + "1299.99" junta textos en lugar de sumar, y luego le pega el 899. Y el programa revienta en toUpperCase porque el segundo producto no tiene nombre. TypeScript no avisó de ninguno, porque le dijimos que los datos eran Producto[] sin comprobarlo.

La respuesta de fetch llega como any; se guarda como unknown y el esquema de Zod la valida y da el tipoDel servidor a un tipo de verdad: fetch, unknown y ZodservidorJSONcomo textores.json()any: TypeScriptdeja de mirarconst datos:unknownhay que comprobarantes de usarsafeParseel esquemalo revisasuccess: truer.data es Producto[]con todos sus tiposerrorcon eldetalletype Producto =z.infer<typeof Producto>sale del mismo esquemaUn solo esquema sirve para dos cosas: comprobar al ejecutary darle a TypeScript el tipo, sin escribirlo dos veces.

La respuesta llega como any: se guarda como unknown y el esquema la valida antes de usarla. Esquema: decodigo.com.

Con Zod: comprobar al llegar

Zod es una biblioteca: se instala con npm install zod. Usamos la versión 4.6.7. Un esquema describe la forma de los datos y z.infer saca de él el tipo de TypeScript, así que no hay que escribirlo dos veces:

import { z } from "zod";

// El esquema describe los datos UNA vez y sirve para dos cosas: validar al ejecutar
// y sacar el tipo de TypeScript.
const Producto = z.object({
  id: z.number().int().positive(),
  nombre: z.string().min(1),
  precio: z.number().nonnegative(),
  etiquetas: z.array(z.string()).default([]),
});
const Productos = z.array(Producto);

type Producto = z.infer<typeof Producto>;
// { id: number; nombre: string; precio: number; etiquetas: string[] }

async function productos(ruta: string): Promise<Producto[]> {
  const res = await fetch("http://localhost:3001" + ruta);
  if (!res.ok) throw new Error(`HTTP ${res.status} en ${ruta}`);
  const datos: unknown = await res.json();
  const r = Productos.safeParse(datos);
  if (!r.success) throw new Error(`Respuesta inesperada de ${ruta}:\n${z.prettifyError(r.error)}`);
  return r.data;
}

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
for (const ruta of ["/productos", "/productos-v2", "/no-existe"]) {
  try {
    const lista = await productos(ruta);
    const total = lista.reduce((suma, p) => suma + p.precio, 0);
    console.log(ruta, "total:", usd.format(total), "|", lista.map((p) => p.nombre).join(", "));
  } catch (e) {
    console.log(e instanceof Error ? e.message : e);
  }
}
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es con-zod.ts
tsc(sin errores)
node con-zod.ts
salida/productos total: $2,198.99 | Audífonos, Webcam
Respuesta inesperada de /productos-v2:
✖ Invalid input: expected number, received string
  → at [0].precio
✖ Invalid input: expected string, received undefined
  → at [1].nombre
HTTP 404 en /no-existe

Ahora el problema se detecta en la frontera, al recibir los datos, y el mensaje dice exactamente qué falla y dónde: [0].precio es texto y [1].nombre no está. El resto del programa trabaja con datos que de verdad tienen la forma del tipo.

PiezaQué hace
const datos: unknown = await res.json()Guarda la respuesta como «todavía no sé qué es». Con unknown, TypeScript no te deja usarla sin comprobarla antes.
Productos.safeParse(datos)Comprueba los datos. Devuelve { success: true, data } o { success: false, error }, sin lanzar excepciones.
Productos.parse(datos)Lo mismo, pero lanza un ZodError si algo no cuadra. Cómodo dentro de un try.
z.infer<typeof Producto>El tipo de TypeScript sacado del esquema. Si cambias el esquema, el tipo cambia solo.
z.prettifyError(error)Convierte el error en un texto legible, con la ruta de cada problema.
.default([])Si el campo falta, pone ese valor. Por eso etiquetas es siempre un array.

El tipo que sale del esquema

El tipo inferido se comporta como uno escrito a mano: avisa si usas una propiedad que no existe o si te falta una al crear un objeto:

import { z } from "zod";
const Producto = z.object({ id: z.number(), nombre: z.string(), precio: z.number() });
type Producto = z.infer<typeof Producto>;

const p: Producto = Producto.parse({ id: 1, nombre: "Webcam", precio: 899 });
console.log(p.precio.toFixed(2));
console.log(p.descripcion);
const otro: Producto = { id: 2, nombre: "Bocina" };
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es tipo-error.ts
tsctipo-error.ts(7,15): error TS2339: La propiedad 'descripcion' no existe en el tipo '{ id: number; nombre: string; precio: number; }'.
tipo-error.ts(8,7): error TS2741: La propiedad "precio" falta en el tipo "{ id: number; nombre: string; }", pero es obligatoria en el tipo "{ id: number; nombre: string; precio: number; }".
Comprueba en la frontera, no en todas partes. Valida los datos en el punto donde entran a tu programa: respuestas de APIs, archivos, formularios, variables de entorno. Una vez validados, el resto del código confía en los tipos. Validar lo mismo en cada función solo añade ruido.

Todo el código de esta página se comprobó con TypeScript 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. Documentación oficial: zod.dev.