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 safeParse para manejar errores sin excepciones.
  • Cómo validar parcialmente con .partial() para PATCH/PUT.