Saltar al contenido

El input con Zod: validación automática y accept form

La propiedad input de una action como un esquema de Zod que valida los argumentos en tiempo de ejecución antes de que el handler corra: la diferencia entre accept json y accept form, el parseo automático de FormData a un objeto tipado, y cómo una sola declaración tipa el cliente y blinda el servidor a la vez.

input: el validador que corre antes que el handler

La propiedad input acepta cualquier validador de Zod —lo normal es un z.object— y actúa como una aduana en la entrada de la action. Cuando llega una petición, Astro comprueba los datos contra ese esquema; si pasan, el handler recibe un argumento ya tipado y ya limpio; si fallan, Astro corta ahí mismo, no llama al handler y responde con un error de código BAD_REQUEST. Es la misma filosofía que rige los schemas de las content collections, ahora aplicada a la frontera cliente-servidor.

TypeScript
// src/actions/index.tsimport { defineAction } from 'astro:actions';import { z } from 'astro:schema';export const server = {  crearTarea: defineAction({    input: z.object({      titulo: z.string().min(1).max(120),      prioridad: z.enum(['baja', 'media', 'alta']).default('media'),      vence: z.coerce.date().optional(),    }),    handler: async (input) => {      // input.titulo es string; input.prioridad, uno de tres literales      return await guardarTarea(input);    },  }),};

El valor de esto se aprecia por contraste. Sin input, el handler recibiría un unknown y tendrías que comprobar a mano que titulo existe, que es una cadena, que no viene vacío, que prioridad es uno de los valores permitidos. Con input, todas esas comprobaciones se declaran una vez y Astro las ejecuta por ti en cada llamada. El handler deja de empezar con un muro de validaciones defensivas y arranca directamente con la lógica, porque lo que recibe ya cruzó la aduana.

Conviene subrayar el orden temporal, porque es la clave de todo: la validación ocurre entre la petición y el handler, nunca dentro de él. Si los datos no cumplen, Astro responde con BAD_REQUEST y tu código no se ejecuta ni una línea, lo que significa que jamás tendrás que contemplar, dentro del handler, el caso de un titulo ausente o de una prioridad inventada. Ese caso murió en la puerta, y el handler vive, por construcción, en un mundo donde su argumento ya es válido.

accept: JSON o formulario

Una action espera sus datos en uno de dos formatos, y la propiedad accept lo declara. Por defecto vale 'json': la action se piensa para ser llamada desde JavaScript con un objeto, como en la lección anterior. Pero si pones accept: 'form', la action pasa a aceptar un FormData, lo que abre la puerta a que un formulario HTML la invoque directamente —incluso sin JavaScript, como veremos más adelante—.

TypeScript
export const server = {  suscribir: defineAction({    accept: 'form',    input: z.object({      email: z.string().email(),      acepta: z.boolean(),      intereses: z.array(z.string()).optional(),    }),    handler: async (input) => {      // input ya no es FormData: es un objeto tipado      return await altaBoletin(input);    },  }),};

Lo notable es lo que ocurre entre el FormData y el handler. Cuando declaras accept: 'form' y un input con z.object, Astro parsea automáticamente el FormData a un objeto que cumple el esquema, coaccionando por el camino los tipos que un formulario siempre entrega como texto. Un campo declarado z.boolean se llena a partir de la presencia de una casilla; uno z.number, convirtiendo la cadena a número; y z.array recoge los valores repetidos de un campo con varias entradas. No manipulas el FormData a mano: declaras la forma final y Astro tiende el puente.

Esta coacción resuelve una incomodidad histórica de los formularios: el navegador entrega todo como texto. Un número viaja como una cadena, una casilla marcada como el valor on y una sin marcar sencillamente no aparece en el envío. Parsear eso a mano es tedioso y frágil; declararlo con Zod —z.number, z.boolean— hace que Astro aplique la conversión correcta en cada caso. El mismo esquema que usarías para una llamada JSON sirve, con accept: 'form', para un envío de formulario, y esa unidad es la que permite que una action atienda las dos vías sin duplicarse.

Del esquema al tipo del cliente

Aquí se cierra el círculo que empezó en la primera lección. El esquema input no solo valida en el servidor: es también la fuente de la que Astro deriva el tipo de los argumentos que el cliente debe pasar. Cuando escribes actions.crearTarea(...), el editor exige un objeto con titulo de tipo string y prioridad entre los tres literales, porque ese contrato se leyó del mismo z.object que valida en tiempo de ejecución. Una sola declaración produce, así, dos garantías que normalmente se escriben por separado: la comprobación dinámica en el servidor y la comprobación estática en el cliente.

Esa doble función explica por qué el input merece cuidado de diseño. Cada refinamiento que añades —un .min(1) que prohíbe el vacío, un .email() que exige una dirección válida, un .enum que cierra el vocabulario— es a la vez una regla que el servidor hace cumplir y una pista que el editor ofrece a quien llama. Endurecer el esquema no encarece la llamada: la clarifica, porque estrecha lo que se considera una entrada legítima justo en el punto donde entra. Y como Zod admite .transform y .refine, el input puede codificar reglas de negocio —normalizar un texto, exigir que dos campos sean coherentes— y no solo tipos.

Conviene, eso sí, no confundir dos cosas que el input mantiene separadas. El esquema garantiza la forma de los datos: que titulo sea una cadena no vacía, que email tenga pinta de correo. No garantiza su legitimidad: que ese correo sea de quien dice, que ese usuario tenga permiso para crear la tarea. La validación de forma vive en el input; la autorización vivirá en el handler, y son planos distintos que la última lección del nivel separará con cuidado.

Reglas de negocio en la frontera

Un esquema no se limita a comprobar tipos: puede exigir condiciones que cruzan varios campos y masajear los datos antes de que el handler los vea. .transform deriva un valor a partir del de entrada —recortar espacios, pasar un correo a minúsculas— de modo que el handler lo recibe ya normalizado. .refine impone una regla que los validadores base no expresan —que dos claves coincidan, que un rango sea coherente— con su propio mensaje.

TypeScript
input: z.object({  email: z.string().email().transform((s) => s.trim().toLowerCase()),  clave: z.string().min(8),  claveRepe: z.string(),}).refine((d) => d.clave === d.claveRepe, {  message: 'Las claves no coinciden',  path: ['claveRepe'],}),

Con path, el mensaje de un .refine se adjunta a un campo concreto y llega al cliente dentro de error.fields.claveRepe, listo para pintarse bajo la casilla correcta. Así, el input deja de ser una lista de tipos y pasa a codificar qué combinaciones de datos son aceptables: la misma frontera que garantiza la forma garantiza también la coherencia. Y como todo esto corre en el servidor, ninguna de estas reglas se puede burlar desde el cliente por mucho que se manipule el formulario.