wandres.dev
TIPAR EL ESTADO · hacer imposible lo inválido

Validación en la frontera: Zod fuera, confianza dentro

Los tipos de TypeScript se borran al compilar, de modo que toda anotación puesta sobre un dato que viene de fuera —una respuesta de API, un formulario, el `localStorage`, la URL, las variables de entorno— no es una comprobación sino una suposición que nadie verificó. Esta lección organiza el sistema en dos regiones: una frontera estrecha donde todo entra como `unknown` y pasa por un esquema de `Zod` que analiza, normaliza y marca, y un núcleo donde los tipos ya son ciertos y sobra el código defensivo. Enseña a derivar el tipo del esquema en lugar de escribirlo dos veces, a elegir entre fallar y degradar según la frontera, a versionar lo que se persiste para sobrevivir al despliegue siguiente y a tratar cada fallo de análisis como la señal de contrato roto que es.

⏱ 19 min

Hay una línea de código que aparece en casi todos los proyectos y que es, técnicamente, una mentira: la que declara que el resultado de convertir una respuesta HTTP en objeto es de tipo Usuario. Ahí no hay comprobación alguna. Los tipos de TypeScript se borran durante la compilación y no dejan ni un byte de código que verifique nada en ejecución, así que esa anotación no comprueba que el servidor devolviera un usuario: solo declara que tú lo esperabas. El día que la API renombra un campo, o devuelve null donde antes había una lista, el programa no falla en la línea del error sino diez funciones más adentro, con un mensaje que no menciona la causa y una pila que no lleva a la frontera. Esta lección propone la disciplina que elimina esa clase entera de fallos: un perímetro estrecho y explícito donde todo lo que entra se analiza contra un esquema, y un interior donde los tipos vuelven a significar lo que prometen y el código puede por fin dejar de defenderse.

🎯 Al terminar esta lección sabrás
  • Inventariar las fronteras por las que entran datos no verificados y reconocer que en todas ellas el tipo es una suposición.
  • Analizar en el perímetro con Zod, derivando el tipo del esquema con z.infer para no mantener dos verdades.
  • Decidir por frontera entre fallar, degradar con un valor por defecto o descartar lo corrupto.
  • Versionar y migrar lo que se persiste, y tratar cada fallo de análisis como una señal de contrato roto.

El inventario de fronteras

Antes de la técnica va el mapa, porque la disciplina solo funciona si el perímetro está completo: una sola puerta sin vigilar devuelve al sistema entero a la situación de partida.

Las fronteras habituales de una aplicación de frontend son más de las que se suelen contar. La respuesta de la API es la evidente. Los formularios son otra, tratada ya en el nivel once. El localStorage y el IndexedDB son la más olvidada, y la más traicionera, porque quien escribió esos datos fue una versión anterior de tu propio código y por eso se les concede una confianza que no merecen. Los parámetros de la URL entran completos bajo control del usuario. Las variables de entorno llegan como texto y a veces no llegan. Y los mensajes entre pestañas, procesos o iframes cruzan sin ningún contrato.

Todas comparten la misma propiedad: al otro lado hay un emisor que puede cambiar sin avisarte, incluidos tú mismo del mes pasado.

La confusión que hay que deshacer es la que iguala anotar con comprobar. Anotar un valor le dice al compilador cómo tratarlo a partir de ese punto; comprobarlo exige código que se ejecute y mire el dato.

En el interior de tu programa las dos cosas coinciden, porque el compilador ha seguido el valor desde su creación y sabe lo que es. En la frontera se separan del todo, porque el valor nació fuera de su vista, y ahí una anotación no es una garantía sino una declaración de intenciones.

De ahí sale la primera regla operativa, casi mecánica: lo que entra se escribe como unknown, nunca como any ni como el tipo que esperas. El unknown obliga a comprobar antes de usar y convierte el olvido en un error de compilación; el any hace justo lo contrario, apaga el compilador exactamente en el punto del programa donde más falta hace.

Merece la pena detenerse en por qué la frontera debe ser además estrecha, y no solo existir. Si el análisis ocurre en veinte sitios repartidos por la aplicación, cada uno con su forma de tratar el fallo, el perímetro deja de ser una línea y pasa a ser una nube, y nadie puede responder a la pregunta de qué entra sin auditarla entera.

Concentrar cada frontera en un módulo con nombre —el cliente de la API, el envoltorio del almacenamiento, el lector de configuración— convierte esa auditoría en abrir cuatro archivos. Y tiene un efecto secundario que se agradece en cuanto el equipo crece: el perímetro se vuelve un lugar visible, así que añadir una puerta nueva sin vigilarla deja de ser un descuido invisible y pasa a ser una omisión que salta en la revisión.

⚠️
El localStorage es una API de la que tú eres el peor cliente

Los datos persistidos vienen del pasado, y el pasado no siguió el esquema de hoy. Cada despliegue que cambia una forma persistida crea usuarios con datos escritos por la versión anterior; si además hubo un fallo a mitad de escritura, hay datos truncados. Y como el localStorage es texto plano en el navegador, cualquiera puede editarlo a mano. Tratar lo que sale de ahí como si tuviera el tipo con el que entró es la causa más común de esa pantalla en blanco que solo le pasa a una persona y que nadie logra reproducir, porque el estado corrupto vive únicamente en su navegador. Analizar al leer, además de resolver el fallo, lo convierte en recuperable: si el análisis no pasa, se descarta y se arranca limpio en lugar de arrastrar la corrupción.

El esquema como aduana

Con el mapa hecho, la aduana es un esquema por frontera. La pieza clave no es la sintaxis de Zod sino la dirección de la dependencia: el esquema es la fuente y el tipo se deriva de él, jamás al revés.

import { z } from "zod";

const esquemaUsuario = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  nombre: z.string().min(1),
  creadoEn: z.coerce.date(),
  rol: z.enum(["admin", "editor", "lector"]),
});

// Una sola verdad: el tipo nace del esquema
export type Usuario = z.infer<typeof esquemaUsuario>;

export async function traerUsuario(id: string): Promise<Usuario> {
  const respuesta = await fetch(`/api/usuarios/${id}`);
  const crudo: unknown = await respuesta.json();
  return esquemaUsuario.parse(crudo); // aquí ocurre la comprobación real
}

Escribir el tipo a mano al lado del esquema parece inofensivo y es el error más caro del enfoque, porque produce dos verdades que empiezan idénticas y divergen al primer cambio que alguien haga solo en una. Con z.infer no hay dos: hay una definición y una proyección suya.

El esquema hace además tres trabajos que un tipo nunca podría hacer, y por eso conviene no verlo como una comprobación sino como una traducción. Comprueba, evidentemente. Convierte, que es lo que hace z.coerce.date al transformar la cadena ISO que viaja por la red en el objeto de fecha que tu lógica quiere manejar. Y normaliza, recortando, bajando a minúsculas o rellenando valores por defecto, de modo que lo que sale de la aduana ya está en forma canónica.

Aquí encaja de forma natural la lección anterior. Un esquema puede terminar en una marca, y entonces el valor que cruza la frontera no solo es correcto: lleva escrito en su tipo que lo es, y ninguna función interior tendrá que volver a preguntárselo.

const esquemaEmail = z.string().trim().toLowerCase().email().brand<"Email">();
type Email = z.infer<typeof esquemaEmail>;

Queda por decidir qué hacer cuando el análisis falla, y la respuesta correcta depende de la frontera. En la carga inicial de un recurso esencial conviene fallar ruidosamente, porque seguir con datos inventados solo desplaza el error a un sitio peor. En una lista de elementos independientes conviene descartar los corruptos y pintar el resto. En el localStorage conviene descartar todo y arrancar limpio. Y en la configuración de entorno conviene fallar en el arranque, nunca a mitad de sesión.

Zod da las dos puertas para eso: parse, que lanza cuando el dato inválido es una anomalía, y safeParse, que devuelve un resultado cuando el dato inválido es una posibilidad prevista que quieres tratar sin excepciones.

flowchart LR
API[API] --> F[Frontera: entra como unknown]
LS[localStorage] --> F
URL[parametros de URL] --> F
ENV[variables de entorno] --> F
F --> Z[esquema Zod analiza y normaliza]
Z -- falla --> D[fallar o degradar segun la frontera]
Z -- pasa --> N[Nucleo tipado sin defensas]
style F fill:#f9e2af,color:#11111b
style Z fill:#cba6f7,color:#11111b
style N fill:#a6e3a1,color:#11111b
style D fill:#f38ba8,color:#11111b

Confiar hacia dentro

La mitad menos comentada de la disciplina es la que da su valor a la otra: si validas en la frontera, dentro no se vuelve a validar. Nunca.

Cuesta aceptarlo porque el código defensivo se siente responsable, pero repetir dentro una comprobación que ya se hizo fuera es peor que redundante. Multiplica los lugares donde vive la misma regla, y esas copias divergen; ensucia cada función con ramas de error que no pueden ocurrir y que ningún test podrá cubrir de verdad; y sobre todo transmite el mensaje de que los tipos no son fiables, lo que empuja a la siguiente persona a defenderse también.

Un núcleo que confía es un núcleo donde las firmas dicen la verdad, y donde las funciones se leen como lógica de dominio en lugar de como aduanas encadenadas.

Hay una prueba sencilla para saber si tu núcleo confía de verdad. Busca las comprobaciones de nulidad que no vienen de un caso del dominio sino de la duda de si el dato llegó bien, y pregúntate qué frontera debería haberlas hecho innecesarias.

Casi siempre la respuesta señala una puerta sin aduana, y esa es la utilidad diagnóstica del principio: el código defensivo del interior es un mapa bastante fiel de los agujeros del perímetro.

La forma arquitectónica que produce esto es reconocible. La frontera es una capa fina y sin lógica de negocio, hecha de funciones que solo traen datos, los analizan y los entregan.

El núcleo recibe tipos y no toca ni la red ni el almacenamiento. Y los tipos internos son los derivados de los esquemas o refinamientos suyos, nunca formas paralelas escritas a mano que alguien tendrá que mantener sincronizadas.

Hay un contrapeso que conviene tener presente, y es el rendimiento. Analizar cuesta, y aunque para respuestas normales el coste es despreciable frente a la propia latencia de red, hay listas de decenas de miles de elementos donde no lo es.

La respuesta no es dejar de analizar, sino analizar una vez, en el sitio correcto y a la granularidad correcta: al entrar el dato, no en cada render ni dentro de un bucle que recorre lo ya validado. Si un caso extremo lo exige, se analiza una muestra o solo los campos de los que se depende, y esa concesión se documenta como lo que es, un agujero consciente en el perímetro.

💡
Analiza también lo que entra en tests y en los datos de ejemplo

El sitio donde la disciplina se rompe primero suele ser el que nadie mira: los ficheros de datos falsos que alimentan los tests y las historias de componentes. Al escribirse a mano con el tipo anotado, no pasan por el esquema, y con el tiempo describen una forma que la API ya no devuelve. Los tests siguen en verde mientras producción falla, que es el peor resultado posible de una suite. Haz que los datos de ejemplo se construyan pasando por el mismo esquema, o genéralos a partir de él: así, el día que el contrato cambia, los tests se rompen antes que los usuarios.

Cuando el contrato cambia

Un esquema no es una foto fija, y la parte madura de la disciplina consiste en planificar su evolución en lugar de sufrirla.

Para los datos que persisten, la técnica es versionar. Se guarda junto al contenido un número de versión, se conserva el esquema de cada versión pasada y se escribe una función de migración por salto, de modo que leer consiste en analizar con el esquema de la versión encontrada y encadenar migraciones hasta la actual. Sin ese número, distinguir un dato viejo de un dato corrupto es adivinar, y la única salida es descartarlo todo en cada despliegue.

Para los datos que llegan de la red, la técnica es la tolerancia asimétrica. Un campo nuevo que el servidor añade no debe romper al cliente, y por eso el comportamiento por defecto de descartar lo desconocido es el adecuado en la mayoría de los casos, mientras que un campo que desaparece sí debe romper, porque tu código dependía de él.

Ahí es donde el análisis en la frontera se paga con creces: el fallo aparece en la línea que analiza, con el nombre del campo y la ruta exacta dentro del objeto, en lugar de aparecer como una propiedad indefinida tres capas más adentro.

De eso se deriva la última práctica, que es de operación y no de tipado. Un fallo de análisis en producción es la señal más valiosa que emite tu sistema, porque significa que un contrato se rompió y lo estás viendo en el instante y en el punto exactos.

Regístralo con la ruta del campo, la frontera implicada y la versión del esquema; no lo silencies detrás de un valor por defecto que oculte el problema. Una aplicación que analiza en la frontera y notifica sus fallos de análisis se entera de los cambios de la API antes que sus usuarios, y esa inversión de orden es toda la diferencia entre mantener un sistema y apagar incendios.

La frontera es el único sitio donde los tipos se ganan; dentro solo se gastan

Interioriza esta asimetría porque reorganiza cómo se reparte el esfuerzo en una base de código. Dentro de tu programa, el compilador ha seguido cada valor desde que nació y sus tipos son verdades demostradas; ahí no hace falta comprobar nada y comprobar sobra. En la frontera ocurre lo contrario: el valor nació fuera de su vista y cualquier tipo que le pongas es una afirmación tuya sin respaldo, tan sólida como la buena voluntad del sistema del otro lado. Toda la seguridad de tipos que disfrutas en las diez mil líneas interiores descansa sobre las diez líneas del perímetro donde alguien decidió analizar de verdad en lugar de anotar, y si esas diez líneas mienten, todo lo demás es una ficción bien tipada que se derrumba en cuanto un servidor cambie un campo. Por eso la pregunta que hay que hacerle a cualquier arquitectura no es cuántos tipos tiene, sino dónde están sus fronteras y si todas ellas analizan; una sola puerta sin vigilar iguala el sistema entero a uno sin tipos, porque el dato corrupto entra por ahí y viaja con la credencial que le diste al anotarlo. Y hay un beneficio que no se ve hasta que se vive: cuando el perímetro es explícito y estrecho, el interior se vuelve un lugar tranquilo donde las funciones hacen una sola cosa y las firmas no mienten, y el código deja de estar escrito en el tono de quien desconfía de todo. Analiza en la frontera para poder confiar dentro; esa es la frase entera, y las dos mitades son igual de importantes.

⚔️ Cierra tu perímetro
  1. Enumera todas las fronteras por las que entran datos a tu aplicación, incluidas el almacenamiento local, la URL y las variables de entorno.
  2. Busca cada punto donde se anota el resultado de una conversión desde JSON con un tipo concreto y sustituye la anotación por unknown.
  3. Escribe el esquema de la frontera más crítica y deriva su tipo con z.infer, borrando cualquier tipo paralelo escrito a mano.
  4. Decide y documenta, para cada frontera, si el fallo debe romper, degradar con un valor por defecto o descartar el elemento corrupto.
  5. Añade número de versión a lo que persistes y escribe la migración desde la versión anterior en lugar de descartar los datos en cada despliegue.
  6. Recorre el núcleo eliminando las comprobaciones que la frontera ya garantiza y anota si alguna de ellas revela una frontera que se te había escapado.