Originally published on AllDevToolsHub.
If you're building anything with LLMs in 2026 — agents, chatbots, structured extraction pipelines — you've hit this bug:
Your prompt says "Respond with valid JSON only."
The model says "Sure! Here's your JSON:" … and then wraps it in markdown fences, renames a field, or returns"price": "29.99"as a string instead of a number.
Your JSON.parse() succeeds. Your app crashes three functions later. The stack trace points nowhere near the actual problem.
The fix isn't a better prompt. The fix is treating LLM output like user input — because that's exactly what it is: untrusted data.
Why LLM JSON breaks (even with JSON mode)
Structured output modes and function calling have gotten good, but they guarantee syntactically valid JSON — not your schema. In production you'll still see:
-
Type drift —
"count": "5"instead of5, especially across model versions -
Missing optional-ish fields — the model decides
metadatawasn't worth including today -
Hallucinated enum values — you asked for
"status": "active" | "inactive", you got"status": "enabled" - Nested shape changes — an array of objects becomes an object of arrays after a model upgrade
- Silent schema rot — you tweak the prompt for one field and the model reshuffles two others
JSON.parse() catches none of these. TypeScript types catch none of these — types are erased at runtime, and the data arrives at runtime.
You need runtime validation. In the TypeScript world, that means Zod.
Step 1: Get a sample response
Call your LLM once and grab a real response. Say you're extracting product info:
{"product":{"name":"Mechanical Keyboard","price":89.99,"in_stock":true,"tags":["peripherals","gaming"]},"confidence":0.94}
If the raw output is a mess (markdown fences, trailing commas, single quotes), paste it into a JSON Formatter first — it validates and pretty-prints in the browser so you can actually see what the model returned. Nothing gets uploaded; it all runs locally.
Step 2: Generate the Zod schema — don't write it by hand
Hand-writing Zod schemas for nested LLM responses is tedious and error-prone (you'll typo a field name and "validate" the wrong shape).
Instead, paste your sample response into a JSON to Zod converter and get this instantly:
import { z } from "zod";
const schema = z.object({
product: z.object({
name: z.string(),
price: z.number(),
in_stock: z.boolean(),
tags: z.array(z.string()),
}),
confidence: z.number(),
});
type LLMResponse = z.infer<typeof schema>;
Free, no signup, runs entirely in your browser — your API responses never leave your machine, which matters if your samples contain real customer data.
Step 3: Tighten the schema
The generated schema is your starting point. Now encode what you actually know:
const schema = z.object({
product: z.object({
name: z.string().min(1),
price: z.number().positive(),
in_stock: z.boolean(),
tags: z.array(z.string()).max(10),
}),
confidence: z.number().min(0).max(1),
});
This is the step that catches hallucinated enums and out-of-range values — the bugs JSON mode will never catch for you.
Step 4: Validate every response with safeParse
async function extractProduct(text: string): Promise<LLMResponse> {
const raw = await callLLM(text);
const result = schema.safeParse(JSON.parse(raw));
if (!result.success) {
// Don't crash — retry with the validation errors in the prompt
console.error("LLM schema violation:", result.error.flatten());
return retryWithFeedback(text, result.error);
}
return result.data; // fully typed, fully validated
}
Two things to notice:
-
safeParse, notparse— LLM failures are an expected case, not an exception. Handle them like one. -
The error feeds the retry. Zod's error messages (
"Expected number, received string at product.price") are precise enough to paste straight back into the model with "Fix these validation errors" — this self-repair loop resolves the majority of schema violations in one retry.
Step 5: Free TypeScript types, zero drift
Because the type is inferred from the schema (z.infer<typeof schema>), your compile-time types and runtime validation can never disagree. Change the schema, the type updates.
If you also need standalone interfaces for other parts of your codebase (API contracts, docs), run the same sample through a JSON to TypeScript converter and you're done.
The 5-minute workflow, recapped
| Step | Tool | Time |
|---|---|---|
| Inspect the raw LLM output | JSON Formatter | 30s |
| Generate the Zod schema | JSON to Zod | 30s |
| Tighten constraints | your editor | 2 min |
Wire up safeParse + retry |
your editor | 2 min |
All the browser tools above are part of AllDevToolsHub — 250+ free developer tools that run entirely client-side. No signup, no upload, no tracking your payloads.
TL;DR
- LLM output is untrusted input. JSON mode guarantees syntax, not your schema.
- Don't hand-write schemas — generate Zod from a real sample response, then tighten.
- Use
safeParseand feed validation errors back into a retry prompt. -
z.inferkeeps runtime validation and compile-time types permanently in sync.
How are you validating AI outputs in production — Zod, JSON Schema, Pydantic, or 🤞? Let me know in the comments.