Skip to content
This page has been auto-translated and may contain errors.View in English

Dónde va la validación

Tres capítulos de esquemas, todos ejecutados a mano sobre objetos escritos unas líneas antes. El input real llega por la red, y un esquema vale algo solo donde ese input aterriza.

La pregunta es dónde poner el chequeo, y la respuesta cambia cómo se escribe el resto del código.

El camino que recorre un envío

Un formulario de registro hace un POST al servidor. Entre que llega la solicitud y corre tu código, pasa una cosa:

text
form  ──POST──▶  validation  ──▶  route handler  ──▶  response

                     └──▶  400 con los errores

La validación se ubica en el medio, y decide cuál de los dos finales recibe la solicitud.

Falla. El esquema encuentra problemas, la solicitud se detiene ahí, y vuelve un 400 Bad Request con una lista de lo que estaba mal. El route handler nunca llega a ejecutarse.

Pasa. Los datos parseados continúan hacia el route handler, que hace el trabajo real y responde con 201 Created.

El handler solo recibe datos que ya coincidían con el esquema.

JunoEl camino que recorre un envío La comparación con el escáner sigue siendo válida, solo que ahora la máquina está en una puerta en lugar de estar sobre una mesa.

Nada llega a la sala que hay detrás sin pasar por ahí, así que la sala puede dejar de preguntarse si las cosas fueron revisadas.

JunoEl camino que recorre un envío El 400 hace más que informar un problema. Es cómo el front end sabe qué campos marcar, y por eso la respuesta lleva una lista de pares campo-y-mensaje y no una sola oración.

El código de estado también importa por sí solo. Un 400 dice que el cliente envió algo mal y debería cambiarlo antes de reintentar. Un 500 dice que el servidor se rompió. Devolver 500 por una validación fallida manda a los clientes a bucles de reintento sobre una solicitud que nunca va a funcionar.

JunoEl camino que recorre un envío Vale la pena ser deliberado sobre qué contiene el body de un 400. Los nombres de campos y tus propios mensajes están bien. El valor recibido no lo está, porque un registro rechazado contiene una contraseña.

Una respuesta de error es un lugar donde la gente se olvida de que está dejando datos por escrito, y lo mismo aplica a lo que sea que registres en ese camino.

También hay una decisión escondida en el diagrama sobre dónde ocurre la transformación. El valor parseado es el que continúa, así que recortar espacios y pasar a minúsculas acá significa que todo lo que viene después ve la versión limpia y ningún handler tiene que acordarse de normalizar.

Ese es el argumento para ponerlo en el esquema, y solo se sostiene mientras los handlers dejen de leer el body crudo.

Por qué el chequeo va adelante

La alternativa es validar al principio de cada route handler, lo cual funciona pero no se sostiene.

js
// La versión que se desvía
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', (req, res) => {
  const result = registerSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ success: false, errors: result.error.issues })
  }

  // ...el trabajo real, eventualmente
})

Cada ruta repite ese bloque. El código repetido se desvía: una ruta mapea los errores y otra los devuelve tal cual, una se olvida del return y sigue de largo hacia el handler después de responder, una ruta nueva copia la versión que tenía más a mano.

Poner el chequeo adelante hace que sea un solo pedazo de código que todas las rutas comparten, y hace que la validación de una ruta se pueda leer desde su definición y no desde su cuerpo.

JunoPor qué el chequeo va adelante Ambas versiones ejecutan el mismo esquema. La diferencia es cuántas copias del código que lo rodea existen.

Una copia se puede arreglar una sola vez. Doce copias significa averiguar cuál de ellas tiene el bug.

JunoPor qué el chequeo va adelante El return faltante es el bug específico que vale la pena conocer, porque falla en silencio. res.status(400).json(...) envía la respuesta y no detiene la función, así que sin un return el handler sigue adelante y procesa los datos inválidos de todas formas.

Terminas con un 400 en el navegador y un usuario creado en la base de datos. Todo se ve correcto desde afuera, que es el peor tipo de error.

JunoPor qué el chequeo va adelante La ganancia más profunda es que la validación se vuelve declarativa y por lo tanto auditable. Cuando cada ruta nombra su esquema en su propia definición, "qué endpoints validan su input" se responde leyendo una lista, y una ruta sin esquema se hace visible en lugar de simplemente estar sin documentar.

Intenta responder esa pregunta en cuarenta handlers que validan internamente cada uno. No podés, sin leer los cuarenta, y la respuesta cambia cada vez que alguien agrega una ruta.

También vale la pena poner el mismo esquema delante del formulario, para que el navegador atrape errores simples sin necesidad de un viaje de ida y vuelta al servidor. Mismo archivo, mismas reglas, y el chequeo del servidor sigue siendo el que realmente cuenta, porque la copia del navegador la puede editar quien sea que lo esté ejecutando.

Qué recibe el handler

Un handler que está detrás de la validación puede ser corto, porque puede asumir la forma que le dieron:

js
import { validate } from './middleware/validate.js'
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', validate(registerSchema), (req, res) => {
  const userData = req.validatedData

  // Lógica de negocio, trabajando con datos que ya coincidían con el esquema.

  res.status(201).json({ success: true, user: { email: userData.email } })
})

Ahora hay tres cosas entre la ruta y el handler: validate(registerSchema) es el chequeo, y el handler corre solo si pasó.

El handler lee req.validatedData, no req.body. Esa distinción es todo el arreglo. req.body es lo que llegó; req.validatedData es lo que sobrevivió, con cualquier transformación ya aplicada.

JunoQué recibe el handler Dos nombres para lo que parece la misma cosa, y la diferencia importa.

req.body es lo que sea que se envió. req.validatedData es lo que pasó el chequeo. Lee el segundo y el handler nunca tiene que preguntarse nada.

JunoQué recibe el handler Este es el hábito que hay que exigir en las revisiones de código: una vez que una ruta tiene validación delante, req.body no debería aparecer en el handler para nada. Una sola lectura suelta de eso esquiva todos los chequeos que armaste.

Es algo mecánico de buscar, lo que lo convierte en una buena regla. Buscá req.body en el handler, y si está ahí detrás de una llamada a validate, ese es el hallazgo.

JunoQué recibe el handler Leer req.body detrás de la validación reintroduce el mass assignment, y vale la pena nombrar por qué. El esquema elimina las claves que no describe, así que el objeto parseado contiene solo lo que pediste. El body crudo todavía contiene todo lo que se envió, incluyendo el isAdmin que un cliente agregó con esperanzas.

La solución es enrutar los datos, no acordarse de una regla. Cuando el valor parseado es el único al que los handlers pueden acceder, olvidarse deja de ser posible, lo cual es mejor que una revisión de código que lo atrapa la mayoría de las veces.

Probalo vos

Acá hay una ruta que valida dentro del handler:

js
import * as z from 'zod'
import { saveSubscriber } from './services/subscribers.js'

const subscribeSchema = z.object({
  email: z.email('Ingresa una dirección de correo válida'),
})

app.post('/api/subscribe', (req, res) => {
  const result = subscribeSchema.safeParse(req.body)

  if (!result.success) {
    res.status(400).json({ success: false, errors: result.error.issues })
  }

  const { email } = req.body
  saveSubscriber(email)

  res.status(201).json({ success: true })
})

Encontrá tres problemas con esto.

Compará tus respuestas

1. Falta el return. res.status(400).json(...) envía una respuesta y sigue ejecutando, así que una solicitud inválida recibe un 400 y además llega a saveSubscriber. El cliente ve un rechazo mientras el suscriptor se guarda igual.

2. Lee req.body en lugar de result.data. Aunque se arregle el return, el valor que se usa es el crudo, así que cualquier recorte de espacios o conversión a minúsculas del esquema se descarta y todo lo que el esquema había eliminado vuelve a estar presente.

3. Devuelve result.error.issues sin cambios. Los objetos de issue de Zod llevan detalle interno, incluyendo el tipo esperado y la restricción que falló, y están pensados para el código, no para las personas. Un cliente necesita pares de campo y mensaje.

Hay una cuarta cosa que no es un bug en esta ruta pero se convierte en uno a gran escala: todo esto vive dentro del handler, así que la próxima ruta recibe una copia, y las copias se van desviando.

Hacia dónde va esto ahora

El plan está definido. Un chequeo delante del handler, un 400 con errores utilizables cuando falla, datos parseados adjuntos a la solicitud cuando pasa.

Middleware de validación lo construye: una función que toma cualquier esquema y devuelve algo que Express puede poner delante de cualquier ruta.