andy.dev

Validaciones con Zod

Validaciones con Zod

Las validaciones suelen aparecer cuando un proyecto ya comenzó a crecer. Al principio basta con comprobar si un campo está vacío o si un correo tiene un formato válido. Unas semanas después aparecen formularios más complejos, APIs, datos provenientes de servicios externos y diferentes equipos consumiendo la misma información.

En ese punto empiezan los problemas:

  • El frontend valida una cosa y el backend otra.
  • Los tipos de TypeScript dicen que un dato existe, pero en producción llega null.
  • Se repiten las mismas validaciones en varios archivos.
  • Los mensajes de error son inconsistentes.

Zod resuelve ese escenario permitiendo definir un único esquema que sirve tanto para validar datos en tiempo de ejecución como para inferir tipos de TypeScript automáticamente.

La principal ventaja es que el esquema deja de ser solamente documentación y pasa a convertirse en una fuente de verdad para toda la aplicación.


Instalación

Con npm:

npm install zod

Con pnpm:

pnpm add zod

Con yarn:

yarn add zod

No requiere configuraciones adicionales y funciona tanto en Node.js como en React, Next.js, Express, NestJS, Bun o cualquier proyecto basado en TypeScript.


El primer esquema

Supongamos que un usuario debe registrarse.

En lugar de escribir validaciones manuales, se define un esquema.

import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive()
});

Ahora cualquier objeto puede validarse utilizando ese esquema.

const result = UserSchema.safeParse({
  name: "Carlos",
  email: "carlos@email.com",
  age: 28
});

if (result.success) {
  console.log(result.data);
} else {
  console.log(result.error.format());
}

safeParse() devuelve un objeto indicando si la validación fue correcta, evitando excepciones.

En aplicaciones web suele ser la opción más cómoda.


Inferir tipos automáticamente

Una de las características más útiles es que no hace falta escribir interfaces duplicadas.

type User = z.infer<typeof UserSchema>;

Ahora User siempre coincide con el esquema.

Si mañana agregas un nuevo campo obligatorio, el tipo cambia automáticamente.

Esto elimina uno de los errores más frecuentes en proyectos grandes: mantener sincronizados los tipos y las validaciones.


Validaciones personalizadas

No todas las reglas vienen incluidas.

Por ejemplo, una contraseña puede requerir:

  • mínimo 8 caracteres
  • una mayúscula
  • un número
const PasswordSchema = z.string()
  .min(8, "Debe tener al menos 8 caracteres")
  .regex(/[A-Z]/, "Debe contener una mayúscula")
  .regex(/[0-9]/, "Debe contener un número");

Su uso es exactamente igual.

PasswordSchema.safeParse("Password123");

Objetos anidados

Es habitual recibir estructuras complejas.

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string()
});

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  address: AddressSchema
});

El esquema se reutiliza fácilmente.

const user = UserSchema.parse({
  name: "Ana",
  email: "ana@email.com",
  address: {
    street: "Av. Principal",
    city: "Lima",
    country: "Perú"
  }
});

Cuando el proyecto tiene decenas de entidades, mantener pequeños esquemas reutilizables resulta mucho más sencillo que crear una única validación gigante.


Arrays

Validar listas también es muy simple.

const ProductSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number()
});

const ProductsSchema = z.array(ProductSchema);

Uso:

const products = ProductsSchema.parse([
  {
    id: 1,
    name: "Mouse",
    price: 39
  },
  {
    id: 2,
    name: "Teclado",
    price: 75
  }
]);

También pueden agregarse restricciones.

const TagsSchema = z.array(z.string()).min(1).max(5);

Campos opcionales

No todos los datos son obligatorios.

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  phone: z.string().optional()
});

También puede definirse un valor por defecto.

const UserSchema = z.object({
  role: z.string().default("user")
});

Cuando el campo no existe, Zod asignará automáticamente "user".


Valores nulos

Hay diferencia entre un campo opcional y uno que acepta null.

z.string().optional();

Permite:

undefined

Mientras que:

z.string().nullable();

Permite:

null

Y si necesitas ambos:

z.string().nullish();

Este detalle suele evitar bastantes errores al consumir APIs externas.


Transformar datos durante la validación

Un esquema también puede modificar valores.

const EmailSchema = z.string()
  .trim()
  .toLowerCase()
  .email();

Entrada:

  USER@EMAIL.COM

Salida:

user@email.com

Otro ejemplo práctico.

const AgeSchema = z.string().transform(Number);

Entrada:

"25"

Salida:

25

Esto resulta muy útil porque muchos formularios envían números como cadenas.


Refinamientos personalizados

Algunas reglas dependen del negocio.

Por ejemplo, una persona debe ser mayor de edad.

const UserSchema = z.object({
  age: z.number()
}).refine(
  data => data.age >= 18,
  {
    message: "Debe ser mayor de edad",
    path: ["age"]
  }
);

También puede validarse la relación entre varios campos.

const RegisterSchema = z.object({
  password: z.string(),
  confirmPassword: z.string()
}).refine(
  data => data.password === data.confirmPassword,
  {
    path: ["confirmPassword"],
    message: "Las contraseñas no coinciden"
  }
);

Este tipo de validaciones suele aparecer en registros, pagos y procesos administrativos.


Compartiendo esquemas entre frontend y backend

Una práctica bastante extendida consiste en crear un paquete compartido.

packages/

shared/
    schemas/
        user.ts

backend/
frontend/

En shared/schemas/user.ts:

import { z } from "zod";

export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email()
});

export type User = z.infer<typeof UserSchema>;

Luego ambos proyectos reutilizan exactamente el mismo esquema.

Frontend:

UserSchema.parse(response);

Backend:

UserSchema.parse(req.body);

Con este enfoque desaparecen muchas inconsistencias entre cliente y servidor.


Validando variables de entorno

Una práctica que termina ahorrando muchas horas de depuración consiste en validar las variables de entorno al iniciar la aplicación.

import { z } from "zod";

const EnvSchema = z.object({
  PORT: z.coerce.number(),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32)
});

const env = EnvSchema.parse(process.env);

export default env;

Si una variable falta o tiene un formato incorrecto, la aplicación falla inmediatamente en lugar de hacerlo minutos después durante una petición.


Integración con Express

Un middleware sencillo puede validar cualquier petición.

import { Request, Response, NextFunction } from "express";
import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email()
});

export function validateUser(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const result = UserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      errors: result.error.flatten()
    });
  }

  req.body = result.data;

  next();
}

Uso:

app.post("/users", validateUser, (req, res) => {
  res.json(req.body);
});

La ventaja es que el controlador recibe datos ya validados.

No necesita repetir comprobaciones.


Integración con React Hook Form

Una combinación muy habitual es React Hook Form junto con Zod.

const FormSchema = z.object({
  name: z.string().min(3),
  email: z.string().email()
});

Con el resolver de Zod, el formulario reutiliza exactamente las mismas reglas que el backend.

Esto evita mantener dos conjuntos de validaciones diferentes.

En equipos grandes, compartir los esquemas suele reducir considerablemente la cantidad de incidencias relacionadas con formularios.


parse() o safeParse()

Ambos métodos validan datos, pero tienen comportamientos distintos.

parse() lanza una excepción.

const user = UserSchema.parse(data);

Si los datos son inválidos, la ejecución se interrumpe.

Es útil cuando un dato inválido representa un error de programación.

Por otro lado:

const result = UserSchema.safeParse(data);

Nunca lanza excepciones.

Devuelve un resultado indicando si la validación fue correcta.

En APIs REST y formularios suele ser la opción más cómoda porque permite responder errores de validación sin depender de bloques try/catch.


Buenas prácticas

  • Mantén los esquemas pequeños y reutilizables.
  • Comparte los esquemas entre frontend y backend siempre que sea posible.
  • Valida las variables de entorno al iniciar la aplicación.
  • Prefiere safeParse() para datos provenientes de usuarios o APIs externas.
  • Utiliza z.infer para evitar interfaces duplicadas.
  • Aprovecha transform() para normalizar datos antes de almacenarlos.
  • Centraliza los mensajes de error cuando el proyecto tenga soporte para varios idiomas.
  • Evita validar únicamente con TypeScript. Los tipos desaparecen en tiempo de ejecución y no protegen frente a datos externos.