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.
También te puede interesar
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.0Dos 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.
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.
| Pieza | Qué 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; }".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.