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.
Un loader trae el contenido; un schema decide qué cuenta como contenido válido. En las content collections ese esquema se escribe con Zod, una librería para describir la forma de un dato y comprobarla. El esquema es el corazón del contrato: allí declaras qué campos existen, de qué tipo son, cuáles pueden faltar y qué valor toman si faltan. Y como Astro lo verifica en el build, un frontmatter mal formado deja de ser una sorpresa en producción para volverse un error que ves al instante.
- Describir la forma de una entrada con
z.objecty los validadores de Zod. - Distinguir un campo opcional con
.optional()de uno con valor por defecto con.default(). - Coaccionar y restringir datos —fechas, enumeraciones, cadenas— dentro del esquema.
- Comprender la validación en build como detección temprana de errores de contenido.
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.
z.coerce.date() es cómodo, pero coacciona con las reglas de JavaScript: acepta cuanto new Date sepa interpretar, lo bueno y lo dudoso. Para fechas de frontmatter suele bastar, porque el formato AAAA-MM-DD se interpreta sin ambigüedad. Cuando el formato sea más libre, un .refine() que rechace fechas inválidas te ahorra que una cadena rara se cuele como una fecha inservible.
Un esquema bien escrito es la mejor documentación del contenido: quien abra content.config.ts ve de un vistazo qué campos existen, cuáles son obligatorios, qué valores admite cada uno y qué defaults se aplican. No es un comentario que puede quedar desactualizado, sino la regla que de verdad se ejecuta. Cuando el esquema y la realidad discrepan, gana el esquema: el build falla hasta que el contenido se ajusta a lo declarado.
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.
flowchart TD
FM[frontmatter del fichero] --> SCH[schema de Zod]
SCH --> OK{cumple el contrato}
OK -->|si| DATA[data tipado y seguro]
OK -->|no| ERR[build detenido con campo senalado]
style DATA fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111bEse 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.
La lección profunda del esquema no es que Zod tenga muchos validadores, sino dónde decides poner la comprobación. Un principio central del diseño de programas robustos dice: analiza y valida los datos en la frontera del sistema, una sola vez, y a partir de ahí trabaja con tipos que garantizan su forma —“parsea, no valides una y otra vez”—. El esquema de una colección es exactamente esa frontera: la aduana por la que el frontmatter, texto sin credenciales escrito a mano, tiene que pasar para convertirse en datos de confianza. Antes de la aduana, todo es sospechoso: un campo puede faltar, una fecha puede ser una cadena, un estado puede estar mal escrito. Después de la aduana, nada de eso es posible, porque lo que no cumplía ni siquiera cruzó. Este movimiento —empujar toda la incertidumbre a un límite estrecho y bien definido, para que el interior del sistema sea un espacio de certezas— es lo que separa el código que se defiende en cada línea del código que confía porque ya comprobó una vez. Y tiene una consecuencia liberadora que se siente al escribir plantillas: nunca tendrás que poner un “por si acaso el título no existe” en tu HTML, porque el esquema garantiza que existe o el sitio no se habría compilado. Diseñar un buen esquema es, entonces, algo más que enumerar campos: es decidir con precisión qué significa que el contenido esté bien, y delegar en la máquina la tarea de no dejar pasar nada que no lo esté. El resto de tu proyecto vive del otro lado de esa aduana, en un mundo donde los datos son, por construcción, lo que dicen ser.
- Escribe un esquema para
blogcontitle,pubDate, undraftcon.default(false)y unostagsopcionales. - Cambia
pubDateaz.coerce.date()y comprueba que ahora una fecha escrita como texto en el frontmatter se acepta y llega comoDate. - Añade un campo
estadoconz.enumy provoca a propósito un valor fuera de la lista; lee el error de build y localiza el fichero culpable. - Razona la diferencia entre marcar un campo
.optional()y darle un.default(), y decide cuál conviene para un campodescription.