Inicio / Artículos / Zod vs express-validator: Dos enfoques para la validación en Express

Zod vs express-validator: Dos enfoques para la validación en Express

Compara la validación de solicitudes basada en esquemas con Zod frente al middleware express-validator basado en cadenas, abordando la configuración, el formato de los errores y las trampas más comunes.

1438 palabras

Manejar entrada no confiable es uno de los primeros problemas que debe resolver cualquier API de Express, y existen más de una forma de hacerlo: desde bibliotecas basadas en esquemas hasta validadores más procedimentales y basados en cadenas. Este artículo analiza ambos enfoques, comenzando con un método orientado a esquemas desarrollado con Zod.

Validación de solicitudes con Zod

Express no realiza ninguna validación por sí mismo en los datos recibidos. Sin una verificación en la entrada, los manejadores de rutas reciben directamente req.body, req.query y req.params tal como están: campos numéricos que en realidad son cadenas, campos que faltan por completo y cargas cuya estructura solo causa problemas una vez llegan a la lógica de negocio.

Zod aborda este problema al permitirte describir las formas de datos esperadas como esquemas basados en TypeScript. Definís un esquema una vez, deriváis un tipo estático a partir de él con z.infer, y analizáis los datos recibidos en la capa HTTP para que todo lo posterior solo reciba datos válidos. Cualquier elemento que no pase la validación puede convertirse en una respuesta HTTP 400 antes de que se ejecute el código del manejador.

Los ejemplos a continuación utilizan Zod 4 (z.email(), z.uuid(), z.coerce), además de un middleware de validación de Express, una herramienta compartida para formatear errores y una lista de posibles problemas.

Requisitos previos

Necesitará la versión 26 de Node.js, Zod 4 (npm i zod) y Express con sus definiciones de tipo (npm i express y npm i -D @types/express). La sintaxis en cadena de Zod 3 más antigua, como z.string().email(), sigue funcionando en la versión 4 pero está obsoleta; prefiera las funciones de nivel superior más recientes que se muestran a continuación.

Declaración de esquemas

// schemas.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.email(),
  name: z.string().min(1).max(100),
  age: z.number().int().min(0).max(150).optional()
});

export type CreateUserInput = z.infer<typeof createUserSchema>;

export const userIdParamSchema = z.object({
  id: z.uuid()
});

export const listUsersQuerySchema = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(10),
  q: z.string().trim().min(1).optional()
});

z.coerce.number() es útil para los valores de cadenas de consulta, ya que todo lo leído desde una consulta HTTP llega como cadena independientemente de su tipo lógico. Es mejor utilizar safeParse en lugar de parse en este punto para mantener el control sobre el estado HTTP resultante y el cuerpo de la respuesta.

Formateo consistente de errores

Convierta ZodError.issues en una única estructura JSON estable en lugar de formatear los errores por separado en cada ruta. Zod 4 también ofrece z.flattenError() para obtener un mapa de errores plano con claves de campo, y z.treeifyError() para una estructura anidada que refleje el esquema.

// format-zod-error.ts
import { ZodError } from 'zod';

export function formatZodError(error: ZodError) {
  return {
    message: 'Validation failed',
    issues: error.issues.map((issue) => ({
      path: issue.path.join('.') || '(root)',
      message: issue.message,
      code: issue.code
    }))
  };
}

Middleware de validación

Valide body, query y params antes de que se ejecute el manejador de la ruta, y luego escriba los valores analizados nuevamente para que el manejador reciba datos tipados y convertidos.

// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';

type RequestSchemas = {
  body?: ZodType;
  query?: ZodType;
  params?: ZodType;
};

export function validate(schemas: RequestSchemas) {
  return (req: Request, res: Response, next: NextFunction) => {
    const parseOrReject = (schema: ZodType, value: unknown) => {
      const parsed = schema.safeParse(value);
      if (!parsed.success) {
        res.status(400).json(formatZodError(parsed.error));
        return null;
      }
      return parsed.data;
    };

    if (schemas.body) {
      const body = parseOrReject(schemas.body, req.body);
      if (body === null) return;
      req.body = body;
    }

    if (schemas.query) {
      const query = parseOrReject(schemas.query, req.query);
      if (query === null) return;
      res.locals.query = query;
    }

    if (schemas.params) {
      const params = parseOrReject(schemas.params, req.params);
      if (params === null) return;
      res.locals.params = params;
    }

    next();
  };
}

Conéctelo en cada ruta de la siguiente manera:

app.post('/users', validate({ body: createUserSchema }), (req, res) => {
  // req.body is CreateUserInput
  res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});

app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
  const { limit, q } = res.locals.query;
  // ...
});

app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
  const { id } = res.locals.params;
  // ...
});

Los resultados de query y params se almacenan en res.locals porque los tipos de Express tratan req.query/req.params como mapas de cadenas simples; reemplazarlos directamente generaría conflictos con ese tipoado.

Riesgos

  • Las cadenas de consulta son siempre cadenas; utilice z.coerce (o z.string() junto con una transformación) para números y valores booleanos.
  • parse lanza un ZodError sin procesar; o bien atrape el error y conviértalo en un 400 por su cuenta, o utilice safeParse en su lugar.
  • Los esquemas de objetos Zod eliminan automáticamente las claves desconocidas; añada .strict() para rechazarlas.
  • Los tipos inferidos como CreateUserInput solo existen en tiempo de compilación; siempre realice la解析 también en los puntos de entrada.
  • En Zod 4, z.uuid() verifica según la especificación más reciente y estricta de UUID; si solo necesita un patrón genérico de ocho, cuatro, cuatro, cuatro y doce dígitos hexadecimales sin las reglas más estrictas, utilice z.guid() en su lugar.

Otra alternativa: validación basada en middleware con express-validator

Zod no es la única forma de evitar que los datos inválidos lleguen a tus controladores. Las aplicaciones Express han dependido durante mucho tiempo de express-validator, una biblioteca creada específicamente como middleware para Express, y adopta un enfoque diferente para resolver el mismo problema.

Imagina una solicitud de registro como esta:

{
  "email": "hello",
  "password": "123"
}

Si un controlador inspecciona directamente este contenido, cada campo requiere una verificación manual, lo que rápidamente se convierte en una serie interminable de condicionales que mezclan la validación con la lógica de negocio:

if (!email) ...
if (!email.includes("@")) ...
if (!password) ...
if (password.length < 8) ...

express-validator traslada esa lógica fuera del controlador y a un paso de middleware dedicado, de modo que la solicitud pasa por el proceso de validación antes de llegar a tu controlador:

Request
   ↓
Validation
   ↓
Controller
   ↓
Business Logic

Esa separación es precisamente el objetivo de la biblioteca: tu controlador queda libre para hacer únicamente lo que está diseñado para hacer.

Para comenzar, instala el paquete:

npm install express-validator

Importe la ayuda body y cree una cadena de validación para cada campo que le interese:

import { body } from "express-validator";
export const registerValidator = [
  body("email")
    .isEmail()
    .withMessage("Invalid email"),  body("password")
    .isLength({ min: 8 })
    .withMessage("Password must contain at least 8 characters"),  body("username")
    .notEmpty()
    .withMessage("Username is required"),
];

Asocie ese middleware a la ruta, antes que al controlador:

router.post(
  "/register",
  registerValidator,
  registerController
);

Definir las verificaciones por sí solas no es suficiente; aún necesita leer los errores recopilados durante la validación:

import { validationResult } from "express-validator";
const errors = validationResult(req);if (!errors.isEmpty()) {
  return res.status(400).json({
    errors: errors.array(),
  });
}

Con esa verificación en su lugar, los payloads inválidos son rechazados con un código 400 antes de que se ejecute cualquier lógica de negocio.

Los validadores integrados cubren bien los casos comunes:

.isEmail()
.isLength()
.notEmpty()
.isInt()

Pero las aplicaciones reales a menudo necesitan reglas que la biblioteca no puede conocer de antemano; por ejemplo, durante el registro podría ser necesario verificar si un correo electrónico ya está en uso. Para eso existe .custom():

body("email")
  .isEmail()
  .bail()
  .custom(async (email) => {
    const user = await User.findOne({ email });
    if (user) {
      throw new Error("Email already registered");
    }    return true;
  });

Los validadores personalizados pueden ser asíncronos, lo que los hace adecuados para búsquedas en la base de datos y otras verificaciones que dependen de la lógica del dominio propio. Observe la llamada a .bail() antes de la verificación personalizada: esta omite el resto de la cadena, incluida la búsqueda asíncrona, si el correo electrónico ya falló en la verificación con .isEmail(), evitando un viaje innecesario a la base de datos.

La elección entre estas dos bibliotecas —o Joi, otra opción consolidada— depende de lo que se adapte a su stack: express-validator es adecuado para proyectos ya construidos alrededor de los middleware de Express, Zod es ideal para entornos basados en TypeScript y con esquemas definidos, mientras que Joi representa una alternativa madura de uso general. No existe una opción universalmente correcta; depende de la arquitectura de su aplicación.

Cualquiera que sea la herramienta que elija, las fortalezas de express-validator son sus validadores integrados, ayudantes para la sanitización, validadores personalizados y asíncronos, su modelo de middleware y el manejo centralizado de errores. Un pipeline de solicitudes en Express bien estructurado generalmente se ve así:

Request
  ↓
Validator
  ↓
Controller
  ↓
Service
  ↓
Database

El objetivo no es solo confirmar que una cadena se parece a una dirección de correo electrónico, sino rechazar las entradas inválidas lo antes posible para que el resto de la aplicación permanezca limpia.

Lecturas relacionadas

  • RFC 9457 explicado: Estandarización de las respuestas de error de la API HTTP — Aprenda cómo el formato Problem Details de RFC 9457 estandariza las respuestas de error de la API HTTP y cómo implementarlo correctamente en una aplicación NestJS.
  • Compartir un único esquema Zod entre tu frontend React y backend Node — Aprenda cómo un solo esquema Zod puede validar formularios de React, respuestas de API, cuerpos de solicitud de Express y variables de entorno, al tiempo que genera tipos TypeScript correspondientes.
  • req-guard-lite: Un limitador de carga minimalista y basado en TypeScript para Express — Aprenda cómo funciona un limitador de carga ligero y sin dependencias para Express, desde configuraciones por defecto en memoria hasta escalado con Redis y generadores de claves personalizados.