StackPractices
beginner Por Mathias Paulenko

Validación de Input: Schemas en 3 Lenguajes

Valida el input de usuarios en el límite de la API con librerías de schemas. Ejemplos con Pydantic, Zod y Jakarta Validation, sanitización y errores estructurados.

Temas: api

Visión General

Todo ataque de inyección, registro corrupto y 500 incomprensible empieza igual: entraron datos al sistema con una forma que nadie verificó. La validación de input rechaza datos malformados, hostiles o absurdos en el límite — antes de que lleguen a la lógica de negocio, a la base de datos o a un log de errores que no dice nada.

La respuesta práctica hoy es la validación schema-first: declaras la forma una vez (“un nombre es un string no vacío de hasta 100 caracteres, una edad es un entero entre 0 y 150”), dejas que una librería haga la verificación y obtienes errores estructurados por campo gratis. Esta receta muestra el mismo schema UserCreate en los tres stacks más usados: Pydantic (Python), Zod (JavaScript/TypeScript) y Jakarta Validation (Java).

Cuándo Usarlo

Usa validación con schemas cuando:

  • Aceptas datos de APIs, formularios o uploads de archivos
  • Procesas datos externos (JSON, CSV, XML) antes de usarlos
  • Aplicas reglas de negocio sobre payloads entrantes (rangos de edad, valores permitidos)
  • Conviertes strings no confiables en valores tipados de forma segura
  • Devuelves errores por campo que una UI pueda mostrar junto a cada input

Para trabajo de sanitización más profundo — escapar HTML, eliminar caracteres de control, normalizar Unicode — combina la validación con sanitización de input y consulta la Guía de Seguridad para el stack defensivo completo.

Cuándo No Usarlo

  • Límites internos entre dos servicios que controla tu equipo, donde el productor ya está tipado y probado
  • Prototipos donde una interfaz TypeScript compartida basta — añade validación en servidor antes de que la toquen usuarios reales
  • Rutas críticas de latencia que revalidan el mismo payload en cada capa; valida una vez en el borde y pasa objetos tipados hacia dentro
  • Cuando una restricción de base de datos es el límite honesto (p. ej. una regla de unicidad — la DB la posee; el schema solo hace los errores más amigables)

Solución

Python (Pydantic v2)

from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator

class UserCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: EmailStr
    age: int = Field(ge=0, le=150)
    bio: str | None = Field(default=None, max_length=500)

    @field_validator('name')
    @classmethod
    def name_must_not_be_blank(cls, v: str) -> str:
        if not v.strip():
            raise ValueError('el nombre no puede estar vacío')
        return v.strip()

# Uso
try:
    user = UserCreate(name="Ada Lovelace", email="ada@example.com", age=36)
    print(user.model_dump())
except ValidationError as e:
    for err in e.errors():
        print(f"{'.'.join(map(str, err['loc']))}: {err['msg']}")

Requiere pip install pydantic[email] para EmailStr.

JavaScript (Zod)

const { z } = require('zod');

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

// Lanza excepción con input inválido
try {
  const user = UserCreate.parse({
    name: 'Ada Lovelace',
    email: 'ada@example.com',
    age: 36,
  });
  console.log(user);
} catch (e) {
  console.error('Error de validación:', e.issues);
}

// safeParse devuelve un objeto resultado en lugar de lanzar
const result = UserCreate.safeParse({ name: '', email: 'bad' });
if (!result.success) {
  for (const issue of result.error.issues) {
    console.error(`${issue.path.join('.')}: ${issue.message}`);
  }
}

Java (Jakarta Validation)

import jakarta.validation.*;
import jakarta.validation.constraints.*;
import java.util.Set;

public class UserCreate {
    @NotBlank
    @Size(max = 100)
    private String name;

    @NotBlank
    @Email
    private String email;

    @Min(0)
    @Max(150)
    private int age;

    @Size(max = 500)
    private String bio;

    public UserCreate(String name, String email, int age, String bio) {
        this.name = name; this.email = email;
        this.age = age; this.bio = bio;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }
    public String getBio() { return bio; }
}

// Uso
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

UserCreate user = new UserCreate("Ada Lovelace", "ada@example.com", 36, null);
Set<ConstraintViolation<UserCreate>> violations = validator.validate(user);

for (ConstraintViolation<UserCreate> v : violations) {
    System.out.println(v.getPropertyPath() + ": " + v.getMessage());
}

Cómo Funciona

flowchart diagram: Petición

El flujo tiene dos puertas, y el orden importa. La validación pregunta “¿tiene la forma correcta?” — las formas incorrectas se rechazan con un 400 y errores por campo. La sanitización pregunta “¿está limpio?” — recortar espacios, escapar HTML, normalizar Unicode ocurre sobre datos que ya pasaron la verificación de forma, antes de que el manejador los almacene o renderice.

Tres propiedades hacen que las librerías de schemas valgan la dependencia:

  • El schema es el contrato. Las restricciones viven junto a los campos que gobiernan, así que “¿qué acepta este endpoint?” tiene una respuesta en un solo archivo — no en if dispersos.
  • La coerción es explícita. Pydantic y Zod pueden convertir "36" en 36, pero solo donde tú lo permitas. La coerción implícita es como "0" se vuelve falsy y atraviesa un if (age) como fantasma.
  • Los errores se agregan. e.errors() / result.error.issues / ConstraintViolations devuelven todas las violaciones, no la primera — el usuario corrige tres campos en un solo intento en lugar de tres.

Validación no es sanitización, y ninguna de las dos es seguridad por sí sola. Un schema que acepta <script> como bio es válido y peligroso. Sanitiza la salida según el contexto (sanitización de input), parametriza las consultas (prevención de SQL injection) y nunca pongas input de usuario en un mensaje de error sin escaparlo.

Variantes

LenguajeLibreríaEstiloIdeal para
PythonPydantic v2Clases de modelo tipadasFastAPI, pipelines de datos
JS/TSZodConstructor de schemas con inferencia TSExpress, tRPC, tipos compartidos cliente/servidor
JavaJakarta ValidationAnotaciones + ValidatorSpring/Jakarta EE, modelos estilo bean
CualquieraJSON SchemaContrato agnóstico de lenguajeAPIs públicas, codegen — ver validate-json-schema

Qué Funciona

  • Valida en el límite. Los controllers/middleware rechazan input inválido; la capa de servicio recibe objetos tipados, no strings crudos.
  • Prefiere allowlists sobre catch-alls. z.enum(['draft','published']) gana a z.string() con un comentario que dice “debería ser uno de…”.
  • Devuelve errores estructurados. {"fields": {"email": "Email inválido"}} gana a un detail en prosa — consulta manejo de errores para formatos de respuesta.
  • Re-valida siempre en servidor. La validación en cliente es UX, no seguridad.
  • Mantén los schemas en módulos compartidos. Si la misma forma se valida en dos endpoints, vive en un solo archivo — dos copias divergen.

Código complementario: El repo companion de input-validation tiene ejemplos ejecutables en Pydantic v2 y Zod, más tests que cubren los caminos de error.

Errores Comunes

  • Confiar en la validación del cliente. Mejora la UX; no detiene nada — cualquiera con curl la salta.
  • Regex donde corresponde un schema. Regex sirve para formatos dentro de un campo (expresiones regulares); úsalo para patrones, no para parsear payloads estructurados.
  • Sorpresas de coerción. Que age: "abc" falle es esperado; que age: "36" se convierta silenciosamente en 36 — o siga siendo string — depende del modo estricto de la librería. Conoce el tuyo.
  • Filtrar internals en mensajes de error. “Column users.email violates unique constraint” le mapea tu esquema a un atacante; devuelve mensajes por campo y registra el detalle.
  • Validar demasiado tarde. Verificar después de db.save() deja estado parcial; valida antes de cualquier mutación.

Lectura Adicional

Preguntas frecuentes

¿Debería validar en el controller o en la capa de servicio?

En el límite — controller o middleware. La capa de servicio debería recibir objetos en los que puede confiar; si tu servicio revalida todo, tu validación de borde no está haciendo su trabajo.

¿Cómo valido objetos anidados y arrays?

Las tres librerías componen: Pydantic anida BaseModels, Zod anida z.object()s y z.array()s, Jakarta propaga con @Valid en campos anidados. Define el schema hijo una vez y referéncialo.

¿Cuál es la diferencia entre validación y sanitización?

La validación verifica si el input cumple restricciones y lo rechaza si no. La sanitización transforma el input — escapar HTML, recortar espacios, normalizar Unicode — para que sea seguro almacenarlo o renderizarlo. Valida primero, sanitiza después, y no confundas "rechazado" con "limpiado".

¿Qué librería debería elegir según mi stack?

Pydantic para Python (y viene gratis con FastAPI), Zod para proyectos JS TypeScript-first, Jakarta Validation para Java/Spring. Para un contrato de API pública agnóstico de lenguaje, JSON Schema.

¿Qué código de estado deberían devolver los errores de validación?

400 Bad Request es el estándar; 422 Unprocessable Content cuando la sintaxis es JSON válido pero la semántica falla — común con validación de schemas. Elige uno y mantenlo consistente entre endpoints.