13  4. Necesitamos confiar en lo que entra (o no confiar)

El cliente manda basura; la validación es la muralla

NotaEn una frase

Vas a aprender a rechazar basura antes de que toque tu lógica: un schema declarativo (Zod / Pydantic / validator) describe la forma válida una vez y los requests malos mueren con 422 antes de llegar al handler.

13.1 El problema

El Cap. 3 terminó con una mentira piadosa: dijimos que 400 era “body inválido”, pero lo único que validamos fue title a mano. Mira lo que el cliente puede mandar de verdad:

{
  "title": 42,
  "priority": "mucho",
  "due_date": "ayer",
  "evil": "<script>alert(1)</script>",
  "unexpected_field": "¿y esto qué?"
}

Tu handler recibe bytes. JSON.parse los convierte en un objeto — pero nadie garantiza que ese objeto tenga la forma que tu lógica espera. En frontend, un dato malo rompe la UI del usuario que lo mandó. En backend, un dato malo entra a la base de datos y corrompe el sistema para todos.

Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.

Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.

13.2 Cómo lo resuelve un equipo

La regla profesional es simple y brutal:

Nada que venga del cliente se usa sin pasar por validación.

Ejemplo del objetivo: que este request

curl -X POST /tasks -d '{"title": "", "priority": 9}'

muera antes del handler con algo así:

422 {
  "error": {
    "code": "VALIDATION",
    "fields": {
      "title": "String should have at least 1 character",
      "priority": "Input should be less than or equal to 5"
    }
  }
}

Y la forma moderna de hacerlo es declarativa: en vez de veinte if sueltos en el handler, describes la forma válida una sola vez — un schema — y dejas que una capa anterior rechace lo que no cumple. El handler solo se ejecuta si el dato ya es válido. En el diagrama del Cap. 1, la validación es el muro que está antes de la lógica de negocio:

El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.

Request → Router → [VALIDACIÓN] → Handler → …
                     ↑ si falla: 400/422 y el handler ni se entera

13.3 Conceptos nuevos

  • U Schema de validación: la declaración de la forma válida — campos, tipos, reglas (min, max, formato email).
  • U Parse, don’t validate: no “revisar y seguir”, sino transformar el JSON crudo en un objeto de tu dominio — si no puede transformarse, se rechaza ahí.

Un dominio es el nombre que compras (midominio.com) y apuntas a tu servidor por DNS. Sin él, tus usuarios tendrían que memorizar una IP. El HTTPS serio requiere dominio.

  • TS Zod: el schema define y genera el tipo TypeScript — z.infer conecta validación de runtime con tipos de compilación.

El runtime es el motor que ejecuta tu código: Node.js es el runtime de JavaScript fuera del navegador; CPython es el de Python; Go compila a binario y el runtime va empaquetado dentro. Es “quien corre” lo que escribiste.

Un compilador traduce código a otra forma: TypeScript → JavaScript (transpila), Go → binario (compila). Atrapa errores antes de ejecutar. tsc, esbuild, go build son compiladores.

  • Py Pydantic + type hints: class TaskCreate(BaseModel) — el type hint es la validación; FastAPI la aplica antes del handler y devuelve 422 solo.

return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.

  • Go Validación manual o struct tags: Go no tiene runtime reflexivo mágico cómodo — se valida explícito o con go-playground/validator sobre struct tags.
  • U 400 vs 422: 400 = JSON roto o semántica general inválida; 422 = JSON bien formado que no cumple el schema (convención popularizada por FastAPI). Elige una y sé consistente.

13.4 La explicación visual

flowchart TB
  Raw["Body crudo<br/>{ title: 42, due: 'ayer' }"] --> S{"Schema<br/>TaskCreate"}
  S -->|"title: string ✗"| R["Rechazo<br/>422 + lista de errores"]
  S -->|"todo válido ✓"| T["Objeto de dominio<br/>TaskCreate validado"] --> H["Handler<br/>(nunca ve basura)"]

El punto clave: el handler vive aguas abajo de la validación. Si tu código de negocio está chequeando tipos, el muro está en el lugar equivocado.

13.5 Implementación

Agregamos el schema TaskCreate real: title requerido (1–200 chars), priority opcional 1–5, due_date opcional en formato ISO.

// Validación sin librería: veinte ifs — el problema antes de la solución
app.post("/tasks", (req, res) => {
  const { title, priority, due_date } = req.body ?? {};
  if (typeof title !== "string" || title.length < 1 || title.length > 200)
    return res.status(422).json({ error: "title invalid" });
  if (priority !== undefined &&
      (typeof priority !== "number" || priority < 1 || priority > 5))
    return res.status(422).json({ error: "priority invalid" });
  // …y esto solo cubre 3 campos de un endpoint
});

Nota lo que falta: el if rechaza pero no produce un objeto tipado — title sigue siendo any para TypeScript.

import { z } from "zod";

const createTaskSchema = z.object({
  title: z.string().trim().min(1).max(200),
  priority: z.number().int().min(1).max(5).optional(),
  due_date: z.string().date().optional(),  // "2026-10-01"
});

// el tipo TS sale del schema — no se escribe dos veces
type TaskCreate = z.infer<typeof createTaskSchema>;

app.post("/tasks", (req, res) => {
  const parsed = createTaskSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(422).json({
      error: "validation failed",
      details: parsed.error.issues.map((i) => ({
        field: i.path.join("."),
        message: i.message,
      })),
    });
  }
  const data: TaskCreate = parsed.data;   // ya es del tipo correcto
  // … crear tarea con data
});
// Valibot — API casi idéntica a Zod, pero modular (bundle más chico)
import * as v from "valibot";
const createTaskSchema = v.object({
  title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
  priority: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(5))),
});

Qué te cuesta: Valibot/ArkType = mismo modelo que Zod, mejor bundle o más velocidad — son swaps directos. Joi es el veterano de Express (mismo concepto, API anterior a TS-first). El patrón es uno: schema → parse → objeto de dominio.

# Validación sobre el dict crudo — lo que Pydantic te ahorra
@app.post("/tasks")
def create_task(request: Request):
    # body = await request.json() → dict sin garantías
    ...
    if not isinstance(body.get("title"), str) or not (1 <= len(body["title"]) <= 200):
        raise HTTPException(422, "title invalid")
    # …y body sigue siendo un dict — sin tipos, sin autocompletar
from datetime import date
from fastapi import FastAPI
from pydantic import BaseModel, Field

class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    priority: int | None = Field(default=None, ge=1, le=5)
    due_date: date | None = None   # Pydantic parsea "2026-10-01" a date

@app.post("/tasks", status_code=201)
def create_task(payload: TaskCreate):
    # si llegas aquí, payload YA es válido — el 422 ocurrió antes
    ...

Si el cliente manda {"title": 42}, FastAPI responde automáticamente:

{
  "detail": [{
    "loc": ["body", "title"],
    "msg": "Input should be a valid string",
    "type": "string_type"
  }]
}
# msgspec — el mismo modelo que Pydantic, compilado: más rápido
import msgspec

class TaskCreate(msgspec.Struct):
    title: str
    priority: int | None = None

Qué te cuesta: msgspec valida igual pero con menos features de conversión. Marshmallow/DRF serializers son la generación anterior: mismo patrón de schema declarativo, más verboso. La idea (declarar la forma, parsear, rechazar antes) es idéntica en todas.

// Sin librería: Decode + ifs explícitos — Go te obliga a ver cada check
var in struct {
    Title    string `json:"title"`
    Priority *int   `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
    http.Error(w, `{"error":"invalid json"}`, 400)
    return
}
if in.Title == "" || len(in.Title) > 200 {
    http.Error(w, `{"error":"title invalid"}`, 422)
    return
}
type CreateTaskInput struct {
    Title    string  `json:"title"    validate:"required,min=1,max=200"`
    Priority *int    `json:"priority" validate:"omitempty,min=1,max=5"`
    DueDate  *string `json:"due_date" validate:"omitempty,datetime=2006-01-02"`
}

func createTask(w http.ResponseWriter, r *http.Request) {
    var in CreateTaskInput
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
        return
    }
    if err := validate.Struct(in); err != nil { // go-playground/validator
        w.WriteHeader(http.StatusUnprocessableEntity)
        json.NewEncoder(w).Encode(map[string]any{
            "error":   "validation failed",
            "details": err.Error(),
        })
        return
    }
    // in es válido — el handler sigue
}
// ozzo — validación por reglas de función en vez de struct tags
func (i CreateTaskInput) Validate() error {
    return validation.ValidateStruct(&i,
        validation.Field(&i.Title, validation.Required, validation.Length(1, 200)),
        validation.Field(&i.Priority, validation.Min(1), validation.Max(5)),
    )
}

Qué te cuesta: validator usa struct tags (declarativo, pero errores crípticos); ozzo usa código normal (más explícito, IDE-friendly). En Go la validación manual también es legítima — no hay magia que esconder.

Agrega validación declarativa al endpoint POST /tasks de mi API en
[Zod+Express / Pydantic+FastAPI / validator+Go]. El body válido es:
{ title: string (1-200 chars, requerido), priority: number (1-5,
opcional), due_date: string ISO date (opcional) }. Requisitos: schema
definido UNA sola vez fuera del handler, rechazo con 422 y errores
estructurados por campo ({field, message}), y que el handler solo se
ejecute si el dato ya es válido. Incluye curl de ejemplo con un body
inválido y la respuesta 422 esperada.

13.6 ¿Por qué cada stack lo hace así?

Lo único que necesitas llevarte: declaras la forma válida una vez y el muro rechaza lo demás — en los tres stacks. La diferencia profunda es qué existe en runtime: los tipos TS se evaporan al compilar, los hints de Python solo son notas… hasta que Pydantic los lee, y los tipos de Go sí viven en el binario. El detalle:

  • TypeScript: los tipos (interface Task) no existen en runtime — se evaporan al compilar. Por eso necesitas Zod: es el puente entre “lo que TS cree” y “lo que realmente llegó”. Si declaras el tipo sin schema, tienes una ilusión de seguridad.
  • Python: los type hints normalmente también son solo para el editor — pero Pydantic los lee en runtime y los convierte en validación. Es el truco que hace a FastAPI tan cómodo: escribes el tipo una vez y sirve para autocompletar, validar y documentar.
  • Go: el tipado estático sí existe en runtime (es compilado), así que un int nunca será un string — pero Go no puede validar “min 1, max 5” en el tipo; por eso las struct tags o el chequeo manual. Menos mágico, más honesto.

La lección universal: el contrato de entrada debe existir fuera del handler, expresado una sola vez, y rechazar antes de ejecutar lógica. Zod, Pydantic y validator son tres implementaciones del mismo muro.

Esta decisión es estructural: validar la entrada antes del handler es obligatorio — es la zapata sin la cual el edificio se cae. Lo intercambiable es cómo declaras la forma válida:

Alternativa Qué es Qué te cuesta Cuándo elegirla
Schema en código (Zod/Pydantic/validator) La forma válida vive junto al handler Una dependencia por ecosistema El default moderno — lo del capítulo
JSON Schema / OpenAPI primero El contrato es un documento; el código se genera Toolchain extra (codegen), curva APIs públicas con muchos consumidores
Validación manual (ifs) Reglas en el handler Se desordena, se olvida, sin tipos Solo un campo trivial — se degrada rápido
Validación solo en frontend El formulario rechaza Nada protege al servidor — curl lo salta Nunca como única defensa (sí como UX)
Validación en DB (constraints) Postgres rechaza el INSERT El error llega tarde y feo Como segunda muralla, no la primera

La cimentación correcta: schema declarativo en el borde + constraints en la DB como respaldo. Lo demás son materiales para el mismo muro.

13.7 Errores comunes

  • Validar dentro del handler con if sueltos: se desordena, se olvida, y cada endpoint termina con reglas distintas.
  • Creer que el tipo TypeScript valida: interface no existe en runtime — req.body es any disfrazado.
  • Devolver errores crudos del validador: el frontend necesita { field, message } estructurado, no el volcado interno de la librería.
  • Validar solo en el frontend: el formulario valida para UX; el servidor valida porque es la última línea de defensa — cualquiera puede hacer curl saltándose tu React.

13.8 Buenas prácticas

  • Schema por operación: TaskCreate (sin id), TaskUpdate (todo opcional), TaskPublic (salida). Un solo schema para todo es deuda.
  • Rechazar campos desconocidos cuando el contrato es estricto (z.object().strict(), model_config = ConfigDict(extra="forbid")).
  • Errores de validación con forma estable: el frontend mostrará “priority: debe ser ≤ 5” junto al campo — dale la estructura para hacerlo.
  • La validación también es seguridad: strings con límite de longitud, enums cerrados y formatos estrictos cortan inyecciones antes de nacer.

13.9 Ejercicio

  1. Agrega a Taskflow el schema TaskCreate de arriba en tu stack y prueba mandar: sin title, priority: 9, due_date: "mañana". Verifica que ninguna llegue al handler.
  2. Diseña TaskUpdate (todo opcional, pero al menos un campo presente) — ¿cómo expresas “al menos uno” en tu validador?

13.10 Mini reto

Haz que un email malformado nunca llegue al handler de un futuro POST /users — en tu lenguaje. Pregunta extra: ¿aceptarías "a@b" como email válido? Investiga qué decide cada librería y por qué la validación de emails es más tramposa de lo que parece.

Ejercicio 1. El schema TaskCreate con Field(min_length=1, max_length=200) / z.string().min(1).max(200) / validate:"required,max=200" rechaza los tres casos con 422 — el handler no se ejecuta.

Ejercicio 2 (TaskUpdate, “al menos un campo”): en Pydantic, model_validator(mode="after") que falle si todos los campos son None; en Zod, .refine(obj => Object.values(obj).some(v => v !== undefined)); en Go, chequeo manual tras Decode. Mismo concepto: la regla “al menos uno” no cabe en un tipo — es un validador cruzado.

Mini reto. Email como campo validado: z.string().email() / EmailStr (Pydantic, requiere email-validator) / validate:"email". Sobre "a@b": Zod y Pydantic lo rechazan (exigen dominio con punto), validator de Go lo acepta por defecto — la RFC 5322 permite dominios sin punto. Lección: “email válido” es una decisión de negocio, no solo de formato — muchos equipos validan formato básico y confirman con un email de verificación (Cap. 14).

13.11 Vocabulario técnico del capítulo

Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.

Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.

Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.

Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.

Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.

Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.

null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.

Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.

Una dependencia es código de terceros que tu proyecto usa: npm install, pip install, go get las traen. Cada una es deuda — ahora funciona, pero hay que mantenerla, actualizarla y confiar en ella.

OpenAPI es el formato estándar para describir una API: cada endpoint, sus parámetros, sus respuestas, sus errores. De ese archivo salen la documentación, los clientes generados y los tests de contrato.

Los roles agrupan permisos: viewer lee, editor escribe, admin gestiona. RBAC = el permiso depende del rol que tienes en ese recurso — puedes ser admin de un equipo y viewer de otro.

Una constraint es una regla que la BD hace cumplir: NOT NULL, UNIQUE, CHECK (price > 0), FOREIGN KEY. Es la última línea de defensa — el código puede tener bugs; la constraint no perdona.

Un backup es una copia de los datos en otro lugar, hecha seguido y probada (un backup que nunca se restauró no es backup, es esperanza). Regla 3-2-1: 3 copias, 2 medios, 1 fuera del sitio.

13.12 Lo que deberías saber hacer ahora