CodeForge

TypeScript de Cero a Experto / TS en el mundo real

Zod: validar en runtime, tipar en compilación

Teoría22 min20 XP

Los tipos se borran, así que no protegen del mundo exterior. Escribir type guards a mano para cada dato es tedioso y frágil. Zod resuelve ambos problemas de un golpe: defines un ESQUEMA una vez, y obtienes DOS cosas sincronizadas —la validación en runtime (que sí corre) y el tipo de TypeScript (que se infiere del esquema). Es el puente definitivo entre los datos externos y tus tipos, y el estándar de facto del ecosistema.

El inspector de aduana con un manual

Instalar y definir un esquema

Zod se instala como cualquier dependencia (pnpm add zod) y se usa definiendo esquemas que describen la forma de tus datos:

esquema.ts
import { z } from "zod";

// un esquema: describe la forma Y las reglas de validación
const UsuarioSchema = z.object({
id: z.number(),
nombre: z.string().min(1),          // string no vacío
email: z.string().email(),          // formato de email válido
edad: z.number().min(0).optional(), // opcional, no negativo
rol: z.enum(["admin", "editor", "lector"]),   // unión de literales
});

// el esquema es un VALOR que sabe validar en runtime.
// pero también lleva dentro toda la información de tipos...

Un esquema Zod describe la forma (como un type) PERO también las reglas de validación (no vacío, email válido, no negativo) —cosas que un tipo de TypeScript no puede expresar. Y a diferencia de un type, el esquema es un VALOR real que existe en runtime y sabe comprobar datos. Fíjate en z.string().email(): eso valida el FORMATO en ejecución, algo imposible solo con tipos.

z.infer: el tipo sale del esquema

Aquí está la joya. Del esquema derivas el tipo de TypeScript automáticamente —una sola fuente de verdad para la validación y el tipado:

infer.ts
import { z } from "zod";

const UsuarioSchema = z.object({
id: z.number(),
nombre: z.string(),
rol: z.enum(["admin", "editor", "lector"]),
});

// deriva el tipo TS del esquema — NO lo escribes a mano:
type Usuario = z.infer<typeof UsuarioSchema>;
// = { id: number; nombre: string; rol: "admin" | "editor" | "lector" }

// usas Usuario como cualquier tipo, y está SIEMPRE sincronizado con el esquema:
function saludar(u: Usuario): string {
return "Hola, " + u.nombre + " (" + u.rol + ")";
}

// cambias el esquema (agregas un campo) → el tipo Usuario se actualiza SOLO.

z.infer<typeof UsuarioSchema> extrae el tipo de TypeScript del esquema (usa el ReturnType/typeof/conditional que aprendiste en los módulos 6 y 7, por dentro). El resultado: defines la forma UNA vez —en el esquema— y obtienes la validación runtime Y el tipo. Imposible que se desincronicen, porque son el mismo objeto. Este es el patrón que hace a Zod imprescindible.

parse y safeParse: validar de verdad

El esquema valida datos con dos métodos, según cómo quieras manejar el fallo:

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

// parse(): devuelve los datos tipados si son válidos, o LANZA si no:
try {
const usuario: Usuario = UsuarioSchema.parse(datosDelServidor);
// si llega aquí, 'usuario' es un Usuario GARANTIZADO (validado en runtime)
} catch (error) {
// ZodError con el detalle exacto de qué campo falló
}

// safeParse(): NO lanza, devuelve un resultado discriminado (módulo 4):
const resultado = UsuarioSchema.safeParse(datosDelServidor);
if (resultado.success) {
resultado.data.nombre;   // 🔍 data es Usuario, seguro
} else {
resultado.error;         // los errores de validación
}

Fíjate en safeParse: devuelve { success: true; data: Usuario } | { success: false; error: ZodError } —¡una discriminated union (módulo 4)!—, así que la manejas con narrowing sin try/catch. parse es más directo pero lanza. La diferencia con el any de response.json(): aquí, si resultado.success es true, los datos están VERIFICADOS en runtime, no solo prometidos.

El patrón completo: fetch + Zod

Todo junto —el cierre honesto del agujero de la lección anterior:

fetch-zod.ts
import { z } from "zod";

const GastoSchema = z.object({
id: z.number(),
nombre: z.string(),
valor: z.number().positive(),
});
type Gasto = z.infer<typeof GastoSchema>;

async function cargarGasto(url: string): Promise<Gasto> {
const respuesta = await fetch(url);
if (!respuesta.ok) throw new Error("HTTP " + respuesta.status);

const crudo: unknown = await respuesta.json();   // unknown, honesto
return GastoSchema.parse(crudo);   // valida en runtime → Gasto GARANTIZADO
// si el servidor mandó basura, esto LANZA aquí, cerca del problema —
// no explota más tarde con un 'undefined' misterioso.
}

La salida de validar un dato malo es clara y temprana:

terminal

cargarGasto con { nombre: 'Café', valor: -5 }

ZodError: [

{ path: ['id'], message: 'Required' },

{ path: ['valor'], message: 'Number must be greater than 0' }

]

¿Cuál es la ventaja de z.infer<typeof Schema> frente a escribir el type Usuario a mano y validar con un type guard separado?

Mini-reto

Diseña con esquema (en un proyecto con pnpm add zod): 1) crea un GastoSchema con nombre (string no vacío), valor (número positivo) y categoria (enum de tres opciones); 2) deriva type Gasto = z.infer<typeof GastoSchema>; 3) usa safeParse sobre un dato válido y uno inválido (valor negativo), manejando el resultado con narrowing (if (resultado.success)). Siente cómo el esquema es la única fuente de la forma, la validación y el tipo.

Qué sigue

Has tipado el DOM y los datos externos. La próxima lección aborda un caso más: usar librerías que NO traen tipos, o describir tipos que existen solo en runtime, con los declaration files (.d.ts) —la forma de darle a TypeScript conocimiento sobre código que no ve.