Diseñar schemas Zod para LLM: tu schema ya es el prompt
Hace unas semanas revisé el pipeline de extracción de facturas de un cliente. Fallaba en uno de cada seis documentos.
El equipo ya había subido los reintentos a tres, puesto temperature: 0 y cambiado a un modelo más caro. Seguía fallando.
Miré el schema de Zod: 31 campos, ninguno con .describe(), ocho z.string() donde solo cabían cuatro valores posibles y cuatro .optional() colocados ahí porque "a veces la factura no lo trae".
El problema no era el modelo. Era el schema. Diseñar schemas Zod para LLM no consiste en describir la forma de tus datos: consiste en escribir instrucciones que el modelo lee antes de responder.
Y esa es la parte que casi nadie aprovecha.
El schema de Zod no espera al final: se envía al LLM dentro del prompt
Cuando usas structured outputs o tool calling, tu schema de Zod no se queda esperando en el servidor a que llegue el JSON. Se convierte a JSON Schema y se envía al modelo en la misma petición.
En Zod 4 puedes ver exactamente lo que sale de tu código:
import * as z from "zod";
const Factura = z.object({
esValida: z.boolean(),
tipo: z.string(),
importe: z.number(),
});
console.log(z.toJSONSchema(Factura));
Eso es literalmente lo que viaja. Y en el caso de Anthropic la documentación del system prompt de tool use lo enseña sin rodeos: cuando llamas a la API con el parámetro tools, el sistema construye un system prompt que incluye tus definiciones tal cual.
In this environment you have access to a set of tools you can use to answer the user39;s question.
...
Here are the functions available in JSONSchema format:
{{ TOOL DEFINITIONS IN JSON SCHEMA }}
Tus nombres de campo, tus tipos, tus descripciones: todo eso son tokens de entrada que el modelo lee antes de generar el primer carácter. Es la misma idea que ya expliqué al hablar de function calling tipado en TypeScript, pero llevada al extremo.
Si el schema es texto en el prompt, entonces un schema mal escrito es un prompt mal escrito. Y no hay reintento que arregle eso.
z.string() es un campo abierto. z.enum() es una pregunta cerrada
Cambiar z.string() por z.enum() es el ajuste con mejor relación esfuerzo/resultado de toda la lista: z.string() deja el espacio de respuesta abierto y z.enum() lo cierra a una lista finita de valores. Es lo primero que toco cuando un pipeline de extracción falla.
// El modelo puede escribir lo que le dé la gana
tipoDocumento: z.string(),
// El modelo solo puede elegir
tipoDocumento: z.enum(["factura", "abono", "recibo", "presupuesto"]),
La diferencia en el JSON Schema generado es esta:
{ "type": "string", "enum": ["factura", "abono", "recibo", "presupuesto"] }
Con z.string() le pides al modelo que invente una etiqueta. Va a devolver "Factura", "FACTURA", "factura simplificada" y "invoice" según el día. Tu Zod lo aceptará todo, porque son strings válidos, y el error aparecerá tres capas más abajo cuando alguien haga un switch.
Con z.enum() el espacio de respuesta está cerrado. Además, cuando el proveedor aplica el modo estricto, el enum se traduce en una restricción real de decodificación: el modelo no puede emitir un valor fuera de la lista.
Regla práctica: si en tu cabeza el campo tiene una lista de valores, escríbela en el schema. Si te da pereza escribirla, es que tampoco la tenías clara tú.
.describe(): el campo que casi nadie usa y que sí llega al modelo
.describe() es el método de Zod que más impacto tiene en la precisión de un LLM y el que casi nadie usa: el texto que le pasas acaba en la clave description del JSON Schema que recibe el modelo, no se queda en tu editor.
fechaVencimientoISO: z
.string()
.describe(
"Fecha límite de pago en formato ISO 8601 (YYYY-MM-DD). " +
"Es la fecha de vencimiento, NO la de emisión. " +
"Si el documento dice 'pago a 30 días', súmalos a la fecha de emisión."
),
No es decorativo. Compruébalo con z.toJSONSchema():
{
"type": "string",
"description": "Fecha límite de pago en formato ISO 8601 (YYYY-MM-DD). Es la fecha de vencimiento, NO la de emisión. Si el documento dice 'pago a 30 días', súmalos a la fecha de emisión."
}
Ese description viaja con el schema. En Zod 4, .describe("texto") es equivalente a .meta({ description: "texto" }) y todos los metadatos se copian al JSON Schema resultante.
La documentación de Anthropic sobre definición de herramientas es tajante al respecto: "Provide extremely detailed descriptions. This is by far the most important factor in tool performance". No es un detalle de estilo. Es el sitio donde metes las reglas de negocio que el nombre del campo no puede expresar.
Dos avisos prácticos:
- La documentación del Vercel AI SDK recomienda encadenar
.describe()o.meta()al final de la cadena, porque la mayoría de métodos de Zod devuelven una instancia nueva que no hereda los metadatos. En mis pruebas conz.toJSONSchema()y Zod 4.5.4 (agosto de 2026) la descripción sobrevivía también antes de.optional(), pero son dos caminos de código distintos y seguir la recomendación no cuesta nada. - Cada descripción son tokens que pagas en cada llamada. Describe los campos ambiguos, no los obvios:
nombreClienteno necesita párrafo.
Si quieres dominar la parte de Zod que no es "poner z.string() y seguir", en mi curso de Zod para validación y transformación de datos en TypeScript trabajo esto con schemas reales de producción.
.optional() es un agujero negro. Dale una salida explícita
.optional() es el error que más alucinaciones fabrica en un schema pensado para un LLM: saca el campo de required sin dejar ninguna señal de cuándo debe omitirse, y el modelo rellena el hueco.
Piensa qué ve el modelo cuando marcas un campo como opcional. Este schema:
z.object({
importeEUR: z.number().nullable(),
nota: z.string().optional(),
});
produce esto:
{
"type": "object",
"properties": {
"importeEUR": { "type": ["number", "null"] },
"nota": { "type": "string" }
},
"required": ["importeEUR"],
"additionalProperties": false
}
Fíjate en nota. Desaparece de required y no queda ninguna otra señal. El modelo no recibe ninguna pista sobre cuándo debe omitirlo ni qué significa su ausencia. Tú sabes que "ausente" quiere decir "no aplica", pero eso está en tu cabeza, no en el prompt.
importeEUR, en cambio, sigue siendo obligatorio y declara null como valor legítimo. El modelo tiene un camino explícito para decir "esto no está".
Esto además encaja con cómo funcionan los structured outputs estrictos de OpenAI, donde todos los campos deben ir en required y la forma documentada de emular un opcional es un tipo unión con null manteniendo el campo obligatorio.
Y hay una versión todavía mejor cuando el "no lo sé" tiene matices:
// Mal: el modelo se inventa una fecha para rellenar el hueco
fechaVencimientoISO: z.string(),
// Regular: puede omitirlo, pero no sabe cuándo
fechaVencimientoISO: z.string().optional(),
// Bien: el "no sé" es una respuesta válida y tipada
vencimiento: z.discriminatedUnion("estado", [
z.object({ estado: z.literal("presente"), fechaISO: z.string() }),
z.object({ estado: z.literal("no_aplica") }),
z.object({ estado: z.literal("ilegible") }),
]),
Un modelo obligado a rellenar un campo que no puede saber rellena igual. Eso no es un bug del modelo, es la consecuencia directa de por qué la IA se inventa cosas: si el schema no ofrece una salida honesta, la salida más probable es una plausible. Diséñale la puerta de "no lo sé" y la usará.
Uniones discriminadas en Zod: dale un mapa al modelo, no un test de opción múltiple
Con z.union(), el JSON Schema resultante es un anyOf de objetos sin nada que los distinga:
// salida recortada de z.toJSONSchema()
{ "anyOf": [ { "properties": { "importeEUR": ... } }, { "properties": { "motivo": ... } } ] }
El modelo tiene que deducir cuál encaja comparando formas. Es una decisión difusa.
Con z.discriminatedUnion() cambia la estructura:
const Movimiento = z.discriminatedUnion("tipo", [
z.object({ tipo: z.literal("pago"), importeEUR: z.number() }),
z.object({ tipo: z.literal("reembolso"), motivo: z.string() }),
]);
Sale un oneOf en el que cada rama lleva { "type": "string", "const": "pago" } en el discriminador. El modelo primero elige una etiqueta —decisión de un token, con opciones cerradas— y a partir de ahí la forma del resto del objeto queda determinada.
Es la misma razón por la que las uniones discriminadas nos gustan en TypeScript: convierten una inferencia estructural en una decisión explícita. Solo que aquí quien se beneficia del narrowing no es el compilador, es el modelo.
Un aviso importante antes de que lo copies: en el modo estricto de OpenAI la raíz del schema tiene que ser un objeto, y la documentación es explícita en que un objeto raíz no puede ser del tipo anyOf. Así que no mandes la unión suelta como en el ejemplo de arriba: anídala dentro de un objeto raíz, como el campo vencimiento de la sección anterior.
Y un detalle más: z.toJSONSchema() emite oneOf para las uniones discriminadas, mientras que la lista de tipos soportados de OpenAI habla de anyOf. Si vas contra structured outputs, pasa por el helper de su propio SDK (zodResponseFormat de openai/helpers/zod) en lugar de por z.toJSONSchema() directo. Contra tool use de Anthropic no tienes esta restricción.
El orden de los campos no es cosmética
Un LLM genera tokens en orden. Si el primer campo de tu objeto es la conclusión, la conclusión se escribe antes de que exista ningún razonamiento en el contexto.
Y el orden lo pones tú. La documentación de structured outputs es explícita: la salida se produce en el mismo orden en que están las claves del schema que envías. Si quieres cambiar el orden, cambias el schema.
// Mal: decide primero y justifica después
const TriajeMal = z.object({
prioridad: z.enum(["alta", "media", "baja"]),
evidencia: z.string(),
});
// Bien: reúne evidencia, luego concluye
const TriajeBien = z.object({
evidencia: z
.string()
.describe("Cita literal del ticket que justifica la prioridad."),
senalesRiesgo: z.array(z.enum(["caida_servicio", "perdida_datos", "cliente_enterprise"])),
prioridad: z.enum(["alta", "media", "baja"]),
});
No es una teoría mía. El ejemplo canónico de razonamiento matemático de la propia documentación de OpenAI pone steps antes de final_answer. El schema es la plantilla del razonamiento, no solo del resultado.
Lo mismo aplica a los nombres. date no dice nada; fechaVencimientoISO dice qué fecha es y en qué formato la quieres. El nombre del campo es contexto gratis: no lo desperdicies en abreviaturas.
Y sobre la profundidad: los schemas planos aciertan más. El modo estricto de OpenAI admite hasta 5.000 propiedades por schema, así que el límite técnico no te va a frenar nunca. El que importa es otro: mucho antes de acercarte a esa cifra ya notarás que un objeto de cuatro niveles produce más fallos que dos llamadas con dos schemas planos.
Dónde termina el diseño del schema y empieza la validación con Zod
Nada de esto elimina la validación. Un schema bien diseñado reduce los fallos en origen; no los lleva a cero, y sigues necesitando safeParse, reintentos y logging.
Esa es exactamente la frontera: este post va de lo que ocurre antes de la llamada. Lo que ocurre después —parseo seguro, limpieza defensiva, reintentos con contexto del error— lo tienes desarrollado en cómo tipar las respuestas de una LLM con Zod y TypeScript.
Ni siquiera tienen que ser el mismo schema. Manda al modelo uno plano y con enums, y transfórmalo después a tu modelo de dominio con las utilidades genéricas de tus wrappers.
Resumen: qué lee el modelo en cada caso
| Lo que escribes en Zod | Lo que lee el modelo | Cuándo usarlo |
|---|---|---|
z.string() |
campo de texto libre, sin restricción | solo texto genuinamente libre |
z.enum([...]) |
"enum": ["a","b"] — lista cerrada |
cualquier campo con valores finitos |
.describe("...") |
"description": "..." — instrucción del campo |
campos ambiguos o con regla de negocio |
.optional() |
el campo desaparece de required, sin más señal |
casi nunca en schemas para LLM |
.nullable() |
"type": ["string","null"] y sigue en required |
cuando "no hay dato" es respuesta válida |
z.discriminatedUnion() |
oneOf con const en el discriminador |
cuando el "no lo sé" tiene matices |
Qué puedes cambiar hoy
Abre el schema que tengas en producción y haz estas cinco pasadas. Te llevará veinte minutos:
- Ejecuta
z.toJSONSchema(tuSchema)y lee la salida. Ese texto es tu prompt. Si te resulta ambiguo a ti, imagina al modelo. - Convierte a
z.enum()todoz.string()que tenga una lista finita de valores. - Añade
.describe()solo a los campos ambiguos, con la regla de negocio y el formato exacto. - Sustituye cada
.optional()por.nullable()o por una rama explícita de "desconocido" en una unión discriminada. - Mueve la conclusión al final y pon delante los campos de evidencia.
En el pipeline de facturas del principio no hizo falta tocar el modelo ni subir más los reintentos: el campo que más fallaba era una fecha obligatoria que el documento a veces no traía, y pasó de inventarse valores a declarar ilegible en cuanto dejó de ser un string a secas.
Si además estás montando el pipeline entero —schema, llamada, validación y reintentos— eso es justo lo que construimos paso a paso en el curso Construye con IA: de la idea al producto, y en Dominicode Labs revisamos schemas reales de proyectos de la comunidad.
La conclusión que quiero que te lleves es una sola: deja de tratar el schema como un portero que revisa la salida del modelo y empieza a tratarlo como la última instrucción que el modelo lee antes de contestar. Cambia el diseño y dejarás de necesitar tantos reintentos.
Preguntas frecuentes
¿El modelo lee de verdad el .describe() de mis campos?
Sí. .describe() se traduce a la clave description del JSON Schema, y ese JSON Schema es lo que se envía al proveedor junto con la petición. En el caso de Anthropic, la documentación muestra que las definiciones de herramientas se insertan en el system prompt en formato JSON Schema. Puedes comprobar exactamente qué se envía ejecutando z.toJSONSchema() sobre tu schema.
¿Usar .optional() está mal siempre?
No, pero casi nunca es lo que quieres cuando el schema va a un modelo. .optional() hace que el campo desaparezca de required sin dejar ninguna señal sobre cuándo omitirlo. Con .nullable() el campo sigue siendo obligatorio y null es una respuesta explícita. Además, el modo estricto de structured outputs de OpenAI exige que todos los campos estén en required y documenta la unión con null como la forma de emular un opcional.
¿Structured Outputs elige el valor correcto o solo el formato correcto?
Garantiza que la estructura encaje con el schema, no que el contenido sea correcto. Un modelo puede devolver una fecha con formato válido y valor inventado, o elegir el enum equivocado. Diseñar bien el schema mejora el acierto semántico; validar después sigue siendo obligatorio.
¿Cuántos campos debería tener un schema para un LLM?
Menos de los que crees. El modo estricto de OpenAI admite hasta 5.000 propiedades por schema, así que el límite que importa no es el técnico sino el de acierto: la degradación empieza muchísimo antes. Si tu schema pasa de veinte campos o de dos niveles, casi siempre sale mejor partirlo en dos llamadas con schemas planos que insistir en una sola extracción gigante.
¿Esto aplica igual con el Vercel AI SDK?
Sí. generateObject y las definiciones de tools convierten internamente tu schema de Zod a JSON Schema con el helper zodSchema, así que las mismas reglas de diseño aplican. La documentación del SDK recomienda además encadenar .describe() o .meta() al final de la cadena para asegurar que los metadatos acaben en el JSON Schema generado.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
