El schema con Zod: tipos, opcionales y validación
Describir la forma de una colección con Zod: z.object y los tipos base, la diferencia entre .optional() y .default(), la coacción de fechas y las enumeraciones, y la validación en tiempo de build que convierte el contenido roto en un error temprano.
Zod: el vocabulario de la forma
Un esquema de Zod es una descripción ejecutable de un dato. z.object declara un objeto y, dentro, cada clave recibe un validador que fija su tipo. Los validadores base cubren lo que aparece en cualquier frontmatter.
import { defineCollection, z } from 'astro:content';import { glob } from 'astro/loaders';const blog = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/blog' }), schema: z.object({ title: z.string(), pubDate: z.date(), draft: z.boolean(), tags: z.array(z.string()), }),});Cada validador es a la vez una promesa de tipo y una regla de comprobación. z.string exige texto, z.number un número, z.boolean un booleano, z.date una fecha, z.array una lista de un tipo dado, y z.object permite anidar objetos dentro de objetos. De ese esquema Astro deriva el tipo de data, así que lo que declaras aquí es exactamente lo que autocompletará tu editor al consultar la colección.
Los tipos se anidan y se combinan sin límite. Un campo puede ser a su vez un objeto, o un array de objetos, describiendo estructuras tan ricas como haga falta.
schema: z.object({ title: z.string(), seo: z.object({ canonical: z.string().url().optional(), noindex: z.boolean().default(false), }), creditos: z.array(z.object({ nombre: z.string(), rol: z.string() })),}),Y como un esquema de Zod es un valor normal de JavaScript, puedes extraer una pieza común a una constante y reutilizarla en varias colecciones. La forma del bloque seo, por ejemplo, se declara una vez y se comparte, de modo que blog y páginas validan sus metadatos con la misma regla. El esquema deja de ser un formulario que copias y se vuelve un conjunto de piezas componibles.
Zod, además, no es una pieza exclusiva de Astro: es una librería del ecosistema de TypeScript que se usa para validar formularios, respuestas de API o variables de entorno. Aprender a describir una colección es, de paso, aprender una herramienta que reaparecerá en muchos otros rincones de tu trabajo. Astro no inventó un lenguaje de esquemas propio, sino que adoptó el que la comunidad ya conocía, y eso reduce a casi nada lo nuevo que tienes que asimilar.
Opcional frente a por defecto
No todos los campos son obligatorios, y Zod distingue dos formas de flexibilidad que conviene no confundir. .optional() permite que el campo falte: si no está, su valor es undefined, y quien lo consuma tendrá que contemplar esa ausencia. .default(valor) también permite que falte, pero rellena el hueco: si no está, Astro le pone el valor por defecto, de modo que el consumidor siempre recibe algo.
La elección tiñe toda la experiencia de quien consume la colección. Cada campo obligatorio es una promesa que simplifica las plantillas; cada opcional, una rama que hay que contemplar; cada default, una comodidad que borra esa rama a cambio de fijar un valor. Diseñar el esquema es, en buena medida, decidir cuánta incertidumbre trasladas aguas abajo y cuánta resuelves aquí, de una vez y para todas las entradas.
Una regla práctica ayuda a decidir: usa .default() cuando exista un valor sensato que casi siempre aplica —un draft en falso, un idioma por defecto— y reserva .optional() para lo que de verdad puede no tener valor —una fecha de actualización que solo existe si hubo edición—. Cuando dudes, inclínate por el obligatorio: cada campo que exiges es una comprobación menos que tus plantillas tendrán que hacer más tarde.
schema: z.object({ title: z.string(), description: z.string().optional(), draft: z.boolean().default(false), pubDate: z.date(),}),La diferencia es de contrato, no de sintaxis. Un description opcional obliga a cada plantilla a preguntarse “y si no hay descripción”. Un draft con .default(false) elimina esa pregunta: todo post tiene un booleano, lo hayas escrito o no. Elegir bien entre ambos es diseñar la ergonomía de quien consume la colección: cada .default() que pones es una comprobación que ahorras aguas abajo. Existe además .nullable(), que admite un null explícito, para los casos en que “vacío a propósito” y “ausente” significan cosas distintas.
Un matiz que ahorra sorpresas: el .default() se aplica después de comprobar el tipo, no antes. Si el campo está presente pero mal tipado, Zod no lo sustituye por el defecto, sino que falla; el defecto solo cubre la ausencia, nunca el error. Así, un draft escrito como "quizas" no se convierte en silencio en false: rompe el build, que es justo lo que quieres. El valor por defecto rellena huecos, no tapa equivocaciones.
Coaccionar, enumerar, restringir
El frontmatter de Markdown no siempre llega con el tipo exacto que quieres, y Zod puede adaptarlo o acotarlo. La coacción es la herramienta para las fechas: z.coerce.date() acepta una cadena como 2026-07-25 y la convierte en un objeto Date de verdad, en lugar de rechazarla por ser texto.
schema: z.object({ title: z.string().min(1), pubDate: z.coerce.date(), estado: z.enum(['borrador', 'publicado', 'archivado']), canonical: z.string().url().optional(),}),z.enum fija un vocabulario cerrado: estado solo puede ser uno de esos tres literales, y cualquier otro valor —un publicdo mal escrito— rompe el build. Las cadenas admiten refinamientos como .min(1) para prohibir el vacío, .url() para exigir una URL válida o .email() para un correo. Con estos ladrillos, el esquema no solo dice de qué tipo es cada campo, sino qué valores concretos son legítimos, estrechando el margen para el error humano.
Cuando ni coaccionar ni enumerar bastan, Zod ofrece dos herramientas más finas. .transform() deriva un valor a partir de otro —normalizar un texto, calcular un campo—, de modo que el dato llega ya masajeado a tus plantillas. .refine() impone una regla que los validadores base no expresan, con su propio mensaje de error.
schema: z.object({ slug: z.string().transform((s) => s.toLowerCase()), pubDate: z.coerce.date(), updated: z.coerce.date(),}).refine((d) => d.updated >= d.pubDate, { message: 'updated no puede ser anterior a pubDate',}),Con transform y refine, el esquema pasa de describir formas a codificar reglas de negocio: no solo qué tipo tiene cada campo, sino qué combinaciones de campos son coherentes entre sí. La validación deja de ser puramente sintáctica y empieza a ser semántica.
La validación en build: el error temprano
Aquí se cierra el círculo con la primera lección. Astro comprueba cada entrada contra su esquema al arrancar el servidor de desarrollo y en cada build, antes de generar una sola página. Si un fichero incumple —le falta title, pubDate no es una fecha, estado trae un valor fuera del enum—, el proceso se detiene y el mensaje señala la colección, la entrada y el campo exactos.
Ese comportamiento —fallar rápido y ruidosamente— es una elección de diseño, no un accidente. Astro podría haber optado por ignorar la entrada inválida y seguir; en cambio detiene todo, porque un sitio a medio validar es peor que uno que no compila: esconde el problema en lugar de mostrarlo. Prefiere una pantalla roja en tu terminal hoy a una página rota ante un usuario mañana.
Ese momento de validación es una frontera: a un lado está el texto sin garantías que escribiste en el frontmatter; al otro, un objeto data cuya forma ya es segura. Todo lo que construyas después —consultas, filtros, referencias— parte de datos que ya no hace falta volver a comprobar, porque ninguno que incumpliera el esquema llegó tan lejos. La validación no se reparte por el código: se concentra en un punto, y ese punto es el esquema.
Concentrarla así tiene una virtud arquitectónica: hay un solo sitio que auditar para saber qué se considera contenido válido. Cuando quieras endurecer una regla —exigir un resumen, acotar unas categorías— la cambias en el esquema y el build reevalúa todo el contenido contra la norma nueva, señalando cada fichero que ya no cumple. La política de calidad del contenido vive en un fichero, no dispersa en cien comprobaciones repartidas por las plantillas.
Hay una simetría con la primera lección que conviene cerrar aquí. Allí vimos que la validación ocurre pronto; aquí vemos con qué se expresa lo que se valida. El loader trajo el contenido, el esquema define su forma, y el build es el momento en que ambos se encuentran y se comprueban. Tres piezas, un solo instante de verdad: si lo pasas, todo lo que venga después puede confiar.