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.
Una action sin input acepta lo que sea: su handler recibe un valor de tipo unknown y le toca a él desconfiar de todo. La propiedad input cambia esa postura de raíz. Es un esquema de Zod que Astro evalúa antes de ejecutar el handler, de modo que, si los datos no cumplen, la action ni siquiera entra: devuelve un error de validación y tu lógica solo corre cuando los argumentos ya son lo que dicen ser. Esa misma declaración cumple un doble papel silencioso: valida en el servidor y, a la vez, tipa la llamada en el cliente. Un esquema, dos garantías.
- Declarar la propiedad
inputde una action con un esquema de Zod y entender cuándo se ejecuta. - Distinguir
accept: 'json'deaccept: 'form'y cuándo conviene cada modo. - Ver cómo
z.objectparsea automáticamente unFormDataa un objeto tipado. - Comprender que el esquema tipa el cliente y valida el servidor con una sola fuente.
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.
// src/actions/index.ts
import { 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.
Omitir input es una decisión, no un olvido inocuo. Para una petición JSON, el handler recibe unknown y toda la responsabilidad de validar recae en ti; para una petición de formulario, recibe un FormData crudo que has de leer campo a campo. Declarar input es delegar en Zod esa carga y ganar, de regalo, los tipos. Reserva el input ausente para actions que de verdad no reciben argumentos, no para ahorrarte un esquema.
El z que usas en el input se importa de astro:schema, el punto de entrada que Astro expone para compartir Zod con las actions y las colecciones. Es el mismo Zod que ya conoces de los esquemas de contenido: no instalas nada aparte ni lo importas del paquete suelto. Reutilizar la misma librería de validación en toda la aplicación —contenido, formularios, actions— es una de las coherencias silenciosas del ecosistema.
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—.
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.
El parseo de formularios cubre los casos que más fricción dan a mano. Un input type="file" se valida con z.instanceof(File), de modo que el fichero subido llega al handler como un objeto File de verdad. Un grupo de casillas o un select múltiple que comparten name se recogen con z.array. Y una casilla suelta encaja con z.boolean, que resuelve el eterno problema de que un checkbox sin marcar sencillamente no aparece en el envío.
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.
flowchart TD
IN[input del cliente o del formulario] --> Z[esquema zod de la propiedad input]
Z --> OK{cumple el contrato}
OK -->|si| H[handler recibe un objeto tipado y limpio]
OK -->|no| BR[error BAD_REQUEST con fields por campo]
H --> DATA[data de exito]
style H fill:#a6e3a1,color:#11111b
style DATA fill:#a6e3a1,color:#11111b
style BR fill:#f38ba8,color:#11111bEsa 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.
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.
input
El esquema de Zod que valida los argumentos antes de que el handler corra.
accept form
Hace que la action reciba un FormData y lo parsea a un objeto tipado.
transform
Normaliza el dato de entrada para que el handler lo reciba ya limpio.
refine
Impone reglas entre campos con un mensaje adjunto al campo culpable.
La elegancia del input no está en Zod, que ya conocías de las colecciones, sino en que un mismo texto sirve simultáneamente a dos mundos que casi nunca comparten fuente. Escribes el esquema una vez y de él brotan, en direcciones opuestas, dos garantías distintas en naturaleza: hacia el cliente, en tiempo de compilación, un tipo que le dice al editor qué argumentos son legítimos antes de ejecutar nada; hacia el servidor, en tiempo de ejecución, un guardián que rechaza en la puerta cualquier petición que no cumpla, venga de tu propio formulario o de un curl malicioso. Es el mismo principio que en las content collections llamamos parsea, no valides una y otra vez, pero aplicado a la frontera más traicionera de la web, la que separa dos máquinas. En el modelo antiguo de endpoints, el tipo del cliente y la validación del servidor eran dos artefactos independientes que un programador disciplinado mantenía en sincronía a fuerza de voluntad; en cuanto uno cambiaba y el otro no, se abría una grieta silenciosa. El input de una action cierra esa grieta por construcción: no hay dos contratos que puedan divergir porque hay uno solo, y el compilador y el runtime lo leen del mismo sitio. La lección que trasciende Astro es que las mejores abstracciones no añaden comprobaciones, sino que colocan una sola comprobación en el punto exacto donde su verdad se propaga en todas las direcciones que importan. Un buen input no es una lista de tipos: es la definición ejecutable de qué significa que una petición sea aceptable, escrita una vez y honrada por igual en las dos orillas del cable.
- Añade a
crearTareauninputcontitulono vacío, unaprioridadconz.enumy unvenceopcional conz.coerce.date, y comprueba que elhandlerrecibe ya los tipos correctos. - Convierte una action a
accept: 'form'y verifica que unFormDatacon un campo de texto y una casilla llega alhandlercomo objeto tipado, sin que lo parsees a mano. - Envía a propósito un
emailmal formado y observa que la action responde conerrorde códigoBAD_REQUESTsin llegar a ejecutar elhandler. - Razona por qué el esquema
inputte ahorra escribir dos veces la misma verdad —una para tipar el cliente, otra para validar el servidor—.