LLMs hallucinate fields, mangle types, and occasionally return syntactically valid JSON that is semantically wrong. Zod schema validation for LLM structured output converts that unreliable text into typed, enforceable contracts your code can consume without fear.
Step 1: Define a strict Zod schema for the shape you expect
Start by modeling the exact data structure you want, not the loose type the model might emit. Zod forces you to declare required fields, types, and constraints up front.
import { z } from 'zod';
export const RecipeSchema = z.object({
name: z.string().min(1),
servings: z.number().int().positive(),
ingredients: z.array(
z.object({
item: z.string(),
quantity: z.string(), // keep as string; models butcher units if coerced
})
),
steps: z.array(z.string()).min(1),
});
export type Recipe = z.infer<typeof RecipeSchema>;
Use z.enum for closed sets, z.union for alternatives, and z.record for dynamic maps. Avoid z.any()—it defeats the purpose.
Why strictness pays off
A model will happily return "servings": "4" as a string. If your downstream code does math, that becomes NaN. Declaring z.number() makes Zod reject it so you can send the error back to the model instead of crashing at runtime.
Step 2: Request structured output with a matching prompt
Modern chat models support JSON modes or tool calls. Use response_format: { type: 'json_object' } with a system prompt that spells out the contract.
import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const sysPrompt = `Return JSON matching this shape:
{
"name": string,
"servings": number,
"ingredients": { "item": string, "quantity": string }[],
"steps": string[]
}`;
async function callModel(userText: string) {
const resp = await client.chat.completions.create({
model: 'gpt-4o-mini',
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: sysPrompt },
{ role: 'user', content: `Extract recipe from: ${userText}` },
],
});
return resp.choices[0].message.content ?? '{}';
}
If you use function calling, the function parameters JSON schema is another place to enforce shape, but Zod remains the local source of truth.
Step 3: Parse and validate with safeParse
Never cast model output directly. Parse the JSON, then run it through the schema.
function validateRecipe(raw: string) {
let json: unknown;
try {
json = JSON.parse(raw);
} catch {
return { ok: false, error: 'invalid json' } as const;
}
const result = RecipeSchema.safeParse(json);
if (!result.success) {
return { ok: false, error: result.error.format() } as const;
}
return { ok: true, data: result.data } as const;
}
safeParse returns a discriminated union. Use result.error.issues to get machine-readable field paths—perfect for feeding back to the model.
Step 4: Retry with targeted feedback
A single failure is normal. Wrap the call in a loop that appends the validation issues to the conversation so the model can self-correct.
async function extractWithRetry(text: string, maxTries = 3) {
const messages = [
{ role: 'system', content: sysPrompt },
{ role: 'user', content: `Extract recipe from: ${text}` },
];
for (let i = 0; i < maxTries; i++) {
const raw = await callModelWithMessages(messages);
const validated = validateRecipe(raw);
if (validated.ok) return validated.data;
messages.push({ role: 'assistant', content: raw });
messages.push({
role: 'user',
content: `Validation failed: ${JSON.stringify(validated.error)}. Return corrected JSON.`,
});
}
throw new Error('could not extract valid recipe');
}
async function callModelWithMessages(messages: any[]) {
const resp = await client.chat.completions.create({
model: 'gpt-4o-mini',
response_format: { type: 'json_object' },
messages,
});
return resp.choices[0].message.content ?? '{}';
}
Two or three tries resolves the vast majority of schema mismatches. Beyond that, fail loudly.
Step 5: Use the inferred type downstream
Because you defined the schema first, the validated object is already typed.
const recipe = await extractWithRetry(userInput);
// recipe is Recipe
console.log(`Making ${recipe.servings} servings of ${recipe.name}`);
No manual interface duplication. If the schema changes, the type changes, and the compiler catches broken call sites.
Coercing messy values
Models emit "4" instead of 4. Use z.coerce.number() or z.preprocess to clean before validation:
const Servings = z.preprocess(
(v) => (typeof v === string ? Number(v) : v),
z.number().int().positive()
);
Do this only when you are confident the coercion is safe; otherwise let validation fail and retry.
Step 6: Route through a resilient gateway if needed
When you front the OpenAI client with an OpenAI-compatible endpoint like n4n.ai—which exposes 240+ models and automatically falls back when a provider is rate-limited—your Zod validation code does not change. You only swap the baseURL and keep the same response_format contract. The schema layer is provider-agnostic by design.
Verify success
You need two kinds of verification: schema unit tests and an integration smoke test.
Schema tests (Vitest)
import { describe, it, expect } from 'vitest';
import { RecipeSchema } from './schema';
describe('RecipeSchema', () => {
it('accepts a well-formed recipe', () => {
const ok = {
name: 'Pancakes',
servings: 2,
ingredients: [{ item: 'flour', quantity: '200g' }],
steps: ['mix', 'cook'],
};
expect(RecipeSchema.safeParse(ok).success).toBe(true);
});
it('rejects negative servings', () => {
const bad = {
name: 'X',
servings: -1,
ingredients: [],
steps: ['noop'],
};
expect(RecipeSchema.safeParse(bad).success).toBe(false);
});
});
Run vitest run. Green means your contract is sound.
Pipeline smoke test
Point your extractWithRetry at a fixed string and assert the returned object passes RecipeSchema and has steps.length > 0. If you can mock the model to return intentionally broken JSON, confirm the retry loop fixes it within maxTries. That proves the full Zod schema validation for LLM structured output loop works end to end.
Closing notes for production
Log the error.issues from failed attempts; they reveal systematic model weaknesses. Cache successful validations per input hash to skip repeat calls. And keep schemas in a shared module so the prompt, the validator, and the TypeScript type never drift apart. Zod schema validation for LLM structured output is not a nice-to-have—it is the boundary that keeps probabilistic generation out of your deterministic business logic.