Creando esquemas con Zod para validar nuestra API
Nuestra API ya “funciona”, pero hay un problema serio: no estamos validando nada. Eso significa que cualquier cliente puede enviarnos datos rotos, incompletos o con formato incorrecto… y nosotros los aceptamos como si nada. Resultado: bugs, datos inconsistentes y dolores de cabeza.
En esta clase solucionamos esto con Zod, una librería de validación por esquemas que nos permite definir de forma declarativa cómo deben ser los datos que entran a la API.
Por qué la validación es responsabilidad del controlador
Aunque usemos MVC y tengamos la API “ordenadita”, si el controlador no valida, realmente no está controlando. Antes de llamar al modelo, debemos comprobar que los datos cumplen las reglas de negocio básicas: tipos, requeridos, límites, etc.
Zod vs TypeScript: build time vs run time
Una duda típica: “si luego usamos TypeScript, ¿para qué Zod?”
- TypeScript valida en build time (durante el desarrollo/compilación).
- Zod valida en run time (cuando la API ya está levantada y recibe datos del exterior).
TypeScript no puede “adivinar” si el JSON que te llega por HTTP es válido. Zod sí puede comprobarlo en ejecución.
Instalación y estructura recomendada
Instalamos Zod y creamos una carpeta para separar los esquemas:
npm i zod
Estructura sugerida:
src/
├── controllers/
├── models/
├── routes/
└── schemas/ # Aquí vivirán nuestros esquemas Zod
Creando el esquema de Job
Vamos a crear un archivo src/schemas/jobs.js (o .ts si ya estás con TypeScript) donde definimos el esquema del recurso job.
// src/schemas/jobs.js
import { z } from 'zod'
export const jobSchema = z.object({
title: z
.string()
.min(3, { message: 'El título debe tener al menos 3 caracteres' })
.max(100, { message: 'El título no puede exceder los 100 caracteres' }),
company: z.string(),
location: z.string(),
// opcional: no obligamos a que venga siempre
description: z.string().optional(),
data: z.object({
technology: z.array(z.string()),
modality: z.string(),
level: z.string(),
}),
})
Ideas extra (según tu dominio)
Zod te permite ir más allá de “string” y ser más específico:
Enums para limitar valores posibles:
const modalitySchema = z.enum(['remoto', 'presencial', 'hibrido'])
Objetos anidados y validaciones compuestas, sin montar un infierno de if.
Validar sin explotar la API: safeParse
Ahora que tenemos el esquema, creamos funciones helper para validación.
¿Por qué safeParse? Porque no lanza excepción: devuelve un objeto tratable, ideal para responder con un 400 sin romper nada.
// src/schemas/jobs.js
import { z } from 'zod'
export const jobSchema = z.object({
title: z.string().min(3).max(100),
company: z.string(),
location: z.string(),
description: z.string().optional(),
data: z.object({
technology: z.array(z.string()),
modality: z.string(),
level: z.string(),
}),
})
export const validateJob = (input) => {
return jobSchema.safeParse(input)
}
Validación parcial para PATCH/PUT
Cuando actualizamos parcialmente (por ejemplo con PATCH), no queremos obligar a que venga todo el objeto: solo lo que se actualiza.
Zod lo hace fácil con .partial():
// src/schemas/jobs.js
export const validatePartialJob = (input) => {
return jobSchema.partial().safeParse(input)
}
Esto permite casos como “solo quiero actualizar el title” sin tener que enviar company, location, etc.
Usando la validación en el controlador
Ejemplo de create (POST) usando validateJob:
// src/controllers/jobs.js
import { validateJob } from '../schemas/jobs.js'
export const createJob = (req, res) => {
const result = validateJob(req.body)
if (!result.success) {
return res.status(400).json({
message: 'Datos inválidos',
errors: result.error.format(),
})
}
// result.data contiene el input ya validado
const job = result.data
// ... aquí llamas al modelo para crear el job
return res.status(201).json(job)
}
Ejemplo de update parcial (PATCH) usando validatePartialJob:
// src/controllers/jobs.js
import { validatePartialJob } from '../schemas/jobs.js'
export const updateJob = (req, res) => {
const result = validatePartialJob(req.body)
if (!result.success) {
return res.status(400).json({
message: 'Datos inválidos',
errors: result.error.format(),
})
}
const partialUpdate = result.data
// ... aquí llamas al modelo para aplicar el update parcial
return res.json({ ok: true, updates: partialUpdate })
}
Ventajas de hacerlo así
- Menos bugs: no entran datos rotos.
- Código más limpio: adiós a los if infinitos.
- Errores consistentes: mensajes claros y controlables.
- Listo para escalar: nuevos recursos, nuevos esquemas, misma estrategia.
Lo que hemos aprendido
- Por qué una API debe validar datos en runtime.
- Cómo definir esquemas declarativos con Zod.
- Diferencia clave entre TypeScript (build time) y Zod (run time).
- Cómo usar
safeParsepara manejar errores sin excepciones. - Cómo validar parcialmente con
.partial()para PATCH/PUT.