You prompted a model for structured data and got back a string that vaguely resembles JSON. To validate llm json output zod is the most pragmatic defense: it turns fuzzy model output into typed, checked objects before they touch your business logic. This guide walks through a complete TypeScript pipeline from schema definition to verified parsing, with retry logic you can ship.
Step 1: Define the data contract with Zod
Before calling any model, write down exactly what shape you expect. Zod gives you a single source of truth that infers a TypeScript type and validates at runtime. Skip JSON Schema if you are already in TS—the inference alone pays for itself.
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 mangle units in numbers
})
),
steps: z.array(z.string()).min(1),
});
export type Recipe = z.infer<typeof RecipeSchema>;
The quantity field is deliberately a string. Models will write "1/2 cup" or "200g", and coercing that to a number silently corrupts data. Validate llm json output zod by constraining only what you can enforce, and leave semantic parsing to dedicated code.
Step 2: Request JSON mode from the model
Most OpenAI-compatible endpoints support response_format: { type: "json_object" }. This instructs the model to emit a single JSON object, but it does not guarantee your schema. You still need the validation layer.
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: "https://api.openai.com/v1", // swap for your gateway
});
async function askForRecipe(topic: string, hint?: string): Promise<string> {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content:
"You are a recipe API. Output only JSON matching: name, servings, ingredients[], steps[].",
},
{ role: "user", content: `Give me a recipe for ${topic}.` },
];
if (hint) {
messages.push({ role: "user", content: `Fix these errors: ${hint}` });
}
const completion = await client.chat.completions.create({
model: "gpt-4o-mini",
messages,
response_format: { type: "json_object" },
temperature: 0.2,
});
return completion.choices[0].message.content ?? "{}";
}
If you route through n4n.ai, the same OpenAI-compatible endpoint exposes 240+ models with automatic fallback when a provider is degraded, so the Zod layer below never changes when you switch backends.
Step 3: Parse and validate safely
Never call JSON.parse and assume it works. Wrap both parsing and validation in a function that returns a typed result or throws a precise error.
import { z } from "zod";
function tryJson(input: string): unknown {
try {
return JSON.parse(input);
} catch {
return undefined;
}
}
async function getValidatedRecipe(topic: string): Promise<Recipe> {
const raw = await askForRecipe(topic);
const json = tryJson(raw);
if (json === undefined) {
throw new Error(`Model returned non-JSON: ${raw.slice(0, 200)}`);
}
// validate llm json output zod at the boundary:
const parsed = RecipeSchema.safeParse(json);
if (!parsed.success) {
throw parsed.error;
}
return parsed.data;
}
safeParse returns a discriminated union instead of throwing, which keeps the caller in control. The moment the data crosses into your app it is already typed as Recipe.
Step 4: Recover from validation failures with retries
Models fail schemas constantly: missing keys, wrong types, hallucinated fields. Feed the Zod issues back into the prompt and retry. This turns a hard failure into a self-correcting loop.
async function getRecipeWithRetry(topic: string, maxTries = 3): Promise<Recipe> {
let lastHint: string | undefined;
for (let attempt = 0; attempt < maxTries; attempt++) {
const raw = await askForRecipe(topic, lastHint);
const json = tryJson(raw);
if (json !== undefined) {
const parsed = RecipeSchema.safeParse(json);
if (parsed.success) return parsed.data;
lastHint = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
} else {
lastHint = "Response was not valid JSON.";
}
}
throw new Error(
`Failed to validate llm json output zod after ${maxTries} attempts: ${lastHint}`
);
}
Keep maxTries low (2–3). Beyond that, the model is stuck and you should surface the error to a human or fallback path.
Step 5: Tighten the schema with refinements
Once the basic shape is enforced, add domain rules. Zod supports .refine() and discriminated unions for polymorphic outputs.
const DrinkSchema = z.object({
type: z.literal("drink"),
name: z.string(),
volumeMl: z.number().positive(),
});
const FoodSchema = z.object({
type: z.literal("food"),
name: z.string(),
calories: z.number().int().nonnegative(),
});
const DishSchema = z.discriminatedUnion("type", [DrinkSchema, FoodSchema]);
// later, to validate llm json output zod for either:
const result = DishSchema.safeParse(json);
Discriminated unions force the model to commit to a type field, which removes ambiguity in downstream switches. If you need cross-field checks (e.g., servings > 0 and ingredients.length >= servings), use .refine() on the object.
Step 6: Verify success with tests
Validation code is infrastructure—test it. Use Vitest to lock the schema against known good and bad payloads, and record one real LLM response for an integration check.
import { describe, it, expect } from "vitest";
import { RecipeSchema } from "./schema";
describe("RecipeSchema", () => {
it("accepts valid model output", () => {
const good = {
name: "Boiled egg",
servings: 1,
ingredients: [{ item: "egg", quantity: "2" }],
steps: ["boil water", "cook 7 min"],
};
expect(RecipeSchema.parse(good)).toEqual(good);
});
it("rejects missing steps", () => {
const bad = { name: "Tea", servings: 2, ingredients: [] };
expect(() => RecipeSchema.parse(bad)).toThrow();
});
it("flags wrong type on servings", () => {
const bad = {
name: "Soup",
servings: "many",
ingredients: [{ item: "water", quantity: "1L" }],
steps: ["heat"],
};
expect(RecipeSchema.safeParse(bad).success).toBe(false);
});
});
For end-to-end verification, save a captured completion.choices[0].message.content to a fixture and assert getValidatedRecipe resolves without throwing. That proves your prompt and schema agree in practice, not just in theory.
Step 7: Production considerations
Log the Zod error issues with the raw response. Those logs are the fastest way to spot prompt drift or a model update that broke your contract. If you cache the system prompt, set provider cache-control hints—gateways that honor them (including n4n.ai) will reduce repeat token cost on every retry.
Streaming partial JSON is out of scope here, but if you adopt it, validate only after the stream closes; Zod cannot check incomplete objects meaningfully. For high-throughput jobs, run validation in a worker so a malformed response doesn’t block the request thread.
The discipline to validate llm json output zod on every call is what separates a demo from a system that survives contact with real models.