n4nAI

Zod schemas for validating LLM structured output

A practical guide to using Zod schema validation for LLM structured output in TypeScript, covering schema design, safe parsing, retries, and verification.

n4n Team2 min read537 words

Audio narration

Coming soon — every post will get a voice note here.

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.

Tagszodtypescriptstructured-outputschema-validation

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured output validation posts →