Validación: esquemas con Zod y errores derivados
La validacion es el ejemplo mas puro de estado derivado dentro de un formulario: los errores no se guardan, se calculan a partir de los valores y de un contrato. Esta leccion propone declarar ese contrato una sola vez, como un esquema de `Zod`, del que se obtienen a la vez la forma de los datos, sus reglas y —gratis— el tipo de TypeScript mediante `z.infer`. Distingue con rigor la validacion por campo, que juzga un valor de forma aislada, de la validacion por formulario, que cruza varios campos con `refine` y `superRefine` para reglas como confirmar una contraseña. Muestra como `safeParse` devuelve un resultado que, aplanado, se convierte en el mapa de mensajes de error por campo, y cierra con lo que casi nadie modela bien: el momento de validar, ese equilibrio entre avisar pronto sin castigar al usuario mientras aun escribe.
De las cinco dimensiones del formulario, la validez es la que mas gente modela mal, porque casi todos la tratan como si fuera estado fuente cuando es el caso mas limpio de estado derivado que existe. Un error no es algo que ocurra y haya que guardar: es una funcion pura de dos entradas —los valores actuales y el contrato que deben cumplir— y por tanto se recalcula, nunca se almacena. La tecnica que mejor encarna esta idea es el esquema declarativo: escribes una vez, con Zod, que forma y que reglas tienen tus datos, y de ese unico enunciado obtienes la validacion, los mensajes y hasta el tipo de TypeScript. Esta leccion convierte la validacion de un amasijo de condicionales dispersos en lo que debe ser: la evaluacion de un contrato contra unos valores, con los errores cayendo por gravedad hacia la vista.
- Tratar los errores de validacion como estado derivado —funcion pura de valores y esquema— y no como estado que se almacena.
- Declarar un unico esquema de
Zodcomo fuente de la forma, las reglas y el tipo viaz.infer. - Distinguir la validacion por campo de la validacion por formulario con
refineysuperRefinepara reglas cruzadas. - Derivar el mapa de mensajes de error por campo desde
safeParsey decidir con criterio el momento de validar.
El esquema como contrato único
Antes de los esquemas, la validacion vivia esparcida: una comprobacion de longitud aqui, una expresion regular alla, un if en el onSubmit y otro en el onBlur, cada uno ignorante de los demas. El problema no era solo la dispersion, sino que la misma verdad —que un correo debe tener forma de correo— se repetia en varios sitios y podia divergir. Un esquema de Zod corrige la raiz: declaras una sola vez la forma y las reglas de tus datos, y ese objeto se convierte en la unica fuente de verdad de la validacion.
import { z } from "zod";
const esquemaRegistro = z.object({
email: z.string().email("Introduce un correo valido"),
password: z.string().min(8, "Minimo 8 caracteres"),
confirmar: z.string(),
edad: z.number().int().min(18, "Debes ser mayor de edad"),
});
// El tipo se deriva del esquema: una sola verdad, no dos
type Registro = z.infer<typeof esquemaRegistro>;
Fijate en la ultima linea, porque es el corazon del enfoque. El tipo Registro no se escribe a mano en paralelo al esquema: se deriva de el con z.infer. Esto elimina la clase entera de bugs en que el tipo dice una cosa y la validacion comprueba otra, porque ambos nacen del mismo enunciado. El esquema es a la vez documentacion, validador y definicion de tipos, y ademas es el mismo contrato que puedes compartir con el backend para que cliente y servidor validen con reglas identicas.
Un matiz que se suele pasar por alto es que un esquema no solo juzga: tambien transforma. Los campos de un formulario HTML llegan como texto —el numero es una cadena, la fecha es una cadena—, y un esquema puede coaccionar y normalizar esa entrada cruda hacia el tipo limpio que tu logica espera. Con z.coerce.number o un transform recortas espacios, conviertes tipos y saneas datos en el mismo lugar donde defines las reglas, de modo que lo que sale del parse ya no es la entrada del usuario sino un valor de dominio bien formado. El esquema es, a la vez, la frontera de validacion y la de saneamiento.
Un esquema, ademas, no tiene por que ser monolitico: se compone. Con operaciones como extend, merge, pick y omit construyes esquemas grandes a partir de piezas pequeñas y reutilizables, de modo que el de crear un usuario y el de editarlo compartan una base comun y solo difieran en lo justo. Esa composicion evita la deriva clasica en que dos formularios que deberian validar igual acaban divergiendo porque nadie recuerda mantener sus reglas en paralelo. El contrato, al ser un valor de primera clase, se hereda y se especializa como cualquier otro dato.
Y como el esquema es un valor, tambien se parametriza: una funcion que recibe, por ejemplo, la lista de dominios de correo permitidos y devuelve el esquema correspondiente convierte una regla que cambia en tiempo de ejecucion en parte del mismo contrato declarativo, sin sacarla a un if suelto que se olvidaria de la validacion por campo. La declaratividad no se rompe por depender de datos: se parametriza.
Por campo y por formulario: dos alcances distintos
No todas las reglas tienen el mismo alcance. Unas juzgan un valor de forma aislada —el correo tiene forma de correo, la contraseña llega a ocho caracteres— y son la validacion por campo: cada regla mira un solo valor y no necesita a los demas. Otras solo tienen sentido cruzando varios campos —la confirmacion coincide con la contraseña, la fecha de fin es posterior a la de inicio— y son la validacion por formulario: no existe ninguna regla que puedas colgar de un campo suelto, porque la verdad vive en la relacion entre dos.
Zod expresa las reglas cruzadas con refine para una condicion simple y con superRefine cuando necesitas emitir varios errores o dirigirlos a campos concretos. La clave es el path: le dices a Zod a que campo pertenece el error para que, al aplanar el resultado, el mensaje aterrice bajo el campo correcto y no en un limbo global.
const esquema = esquemaRegistro.superRefine((datos, ctx) => {
if (datos.password !== datos.confirmar) {
ctx.addIssue({
code: "custom",
message: "Las contraseñas no coinciden",
path: ["confirmar"], // el error aterriza bajo el campo confirmar
});
}
});
Es tentador validar la confirmacion de contraseña dentro del propio campo confirmar, pero ahi no vive la informacion necesaria: para saber si coincide hace falta leer tambien el campo password. Toda regla que dependa de mas de un valor es, por definicion, validacion por formulario, y por eso refine y superRefine operan sobre el objeto completo y no sobre un valor suelto. Confundir el alcance produce el bug clasico: el mensaje de que no coinciden no desaparece cuando corriges el otro campo, porque lo colgaste del campo equivocado. Dirige siempre el error con path al campo que el usuario debe tocar para resolverlo.
Los esquemas tambien capturan reglas que dependen de la forma, no solo del valor. Un campo que solo es obligatorio cuando otro tiene cierto valor —el numero de piso solo si el tipo de via lo exige, el motivo solo si marcaste “otro”— se expresa con uniones discriminadas: Zod elige que sub-esquema aplicar segun un campo discriminante, y con el cambian a la vez las reglas y el tipo. Asi la validacion condicional deja de ser una maraña de if anidados y pasa a ser lo que en realidad es: varias formas legitimas de los datos, cada una con su contrato, seleccionadas por el campo que decide en cual estas.
Y como los mensajes forman parte del esquema, tambien son derivados y traducibles: la cadena que acompaña a cada regla puede salir de tu sistema de internacionalizacion en lugar de estar incrustada, de modo que el mismo contrato hable el idioma del usuario sin duplicar la logica. El error no solo se calcula, se calcula ya redactado en la lengua correcta.
Del resultado a los mensajes: errores derivados
Una vez tienes el esquema, validar es una sola llamada. safeParse no lanza excepciones: devuelve un resultado con un success booleano y, si fallo, un objeto de error con la lista de problemas. De ese resultado se deriva el mapa de mensajes que la vista consumira, campo por campo. Ni un solo mensaje se guarda en estado: todos se calculan a partir de los valores y del esquema, exactamente como prometia el marco de estado derivado.
function validar(valores: unknown) {
const resultado = esquema.safeParse(valores);
if (resultado.success) return { valido: true, errores: {} };
// flatten reduce la lista de issues a un mapa campo -> mensajes
const { fieldErrors } = resultado.error.flatten();
return { valido: false, errores: fieldErrors };
}
Zod ofrece dos puertas: parse, que lanza una excepcion si los datos no cumplen, y safeParse, que devuelve un resultado con success y, en su caso, el error. En un formulario quieres siempre safeParse: los datos invalidos no son un fallo excepcional del programa, son el estado normal y esperado mientras el usuario escribe, y modelar lo esperado con excepciones convierte el control de flujo en un via crucis de try y catch. Reserva parse para las fronteras donde un dato invalido si es una anomalia —al leer una respuesta que dabas por bien formada— y deja safeParse para el ir y venir cotidiano del formulario.
El flujo completo es una tuberia sin memoria: los valores entran, el esquema los juzga, safeParse produce un resultado, flatten lo reduce a un mapa por campo y la vista pinta cada mensaje bajo su campo. Si el valor cambia, se recorre la tuberia otra vez y los mensajes se recalculan. No hay estado de error que sincronizar porque no hay estado de error: hay una funcion que, dados unos valores, devuelve unos mensajes.
flowchart LR VAL[valores] --> SP[safeParse contra el esquema] SP --> RES[resultado success o error] RES --> FL[flatten a mapa por campo] FL --> UI[mensajes bajo cada campo] style VAL fill:#89b4fa,color:#11111b style SP fill:#cba6f7,color:#11111b style RES fill:#cba6f7,color:#11111b style FL fill:#a6e3a1,color:#11111b style UI fill:#f9e2af,color:#11111b
Queda la pregunta que casi nadie modela bien: cuando recorrer esa tuberia. Validar en cada tecla castiga al usuario mostrandole errores de un campo que aun no ha terminado de escribir; validar solo al enviar lo deja avanzar a ciegas hasta el final. La estrategia madura es hibrida y depende de touched, la dimension de la leccion uno: no muestres el error de un campo hasta que el usuario lo haya abandonado al menos una vez, y a partir de entonces valida en vivo para premiar la correccion con feedback inmediato. La validez se calcula siempre; lo que touched gobierna es cuando se enseña.
Hay una frontera donde este modelo limpio se tensa: la validacion asincrona. Comprobar si un nombre de usuario esta libre exige preguntar al servidor, y eso ya no es una funcion pura de valores y esquema, sino una operacion con latencia y estado propio —pendiente, resuelta, fallida—. La disciplina aqui es no dejar que lo asincrono contamine lo sincrono: la validacion local por esquema sigue siendo instantanea y derivada, y la comprobacion remota se trata como una pieza aparte, con su propio indicador de “comprobando”, que suele lanzarse con retardo tras dejar de escribir. Confundir ambas convierte cada tecla en una peticion de red y el formulario en un martillo contra tu propio backend.
La consecuencia estrategica de declarar la validacion como un esquema es que ese mismo objeto puede vivir en un paquete compartido entre cliente y servidor. El backend valida con el mismo Zod que el frontend, y herramientas como tRPC construyen sobre esa idea contratos de extremo a extremo donde el tipo de la peticion se infiere del esquema. La validacion del cliente deja entonces de ser una barrera que el servidor tiene que reimplementar y desconfiar: es la misma verdad, evaluada antes para dar feedback rapido y evaluada de nuevo en el servidor porque el cliente nunca es de fiar. Un contrato, dos evaluaciones, cero divergencia.
El cambio de mentalidad que esta leccion pide es dejar de pensar en la validacion como algo que haces y empezar a pensarla como algo que declaras. No escribes codigo que comprueba y guarda errores; escribes un contrato —el esquema— y dejas que los errores se deriven de el cada vez que los valores cambian, como una sombra que sigue al objeto sin que nadie la almacene. De ahi salen tres regalos que se refuerzan entre si. El primero es que el tipo de TypeScript nace del mismo esquema con z.infer, asi que forma, reglas y tipos nunca divergen. El segundo es que el mismo contrato viaja al backend, y cliente y servidor validan con reglas identicas en lugar de reimplementarlas cada uno a su manera. El tercero es que, al no almacenar los mensajes, desaparece la clase entera de bugs en que el error mostrado contradice al valor real, porque no hay dos verdades que sincronizar. Y sobre todo eso, una sola decision de diseño te separa del formulario que enfada al usuario: distinguir el calculo de la validez —que ocurre siempre— del momento de mostrarla —que espera a touched—. Validar es evaluar un contrato contra unos valores; mostrar es cuestion de tacto.
- Toma un formulario con validacion dispersa en
ify reescribe todas sus reglas como un unico esquema deZod, sin dejar ninguna comprobacion fuera. - Deriva el tipo de los datos con
z.infersobre ese esquema y borra cualquier tipo que tuvieras escrito a mano en paralelo. - Identifica una regla que cruce dos campos, expresala con
superRefiney dirige el error conpathal campo que el usuario debe corregir. - Escribe la funcion que llama a
safeParsey aplana el error a un mapa por campo, y comprueba que no guarda ni un solo mensaje en estado. - Implementa la regla de mostrar el error de un campo solo cuando su
touchedsea verdadero, y observa como el formulario deja de castigar al usuario mientras escribe. - Comparte mentalmente ese esquema con un backend imaginario y anota que reglas dejarian de reimplementarse por duplicado si ambos lados validaran con el mismo contrato.