Get JSON You Can Trust from an LLM (with Zod Validation)
Ask a model for JSON, validate it with a Zod schema, and retry once with the error message, so your code only ever sees data with the right shape.
By Avnish Yadav
Free-form text is fine for a chat window, but your code needs data: a category, a list of tags, a yes/no decision. In this lesson you'll ask a model for JSON, check it with a schema, and handle the times it gets it wrong, so the rest of your app only sees valid data.
You'll need: the setup from the previous lesson, TypeScript, and the zod package (npm install zod).
Why "just ask for JSON" isn't enough
If you write "reply in JSON" in a prompt, you'll usually get JSON. But sometimes you'll get JSON wrapped in a sentence, a missing field, a number written as a string, or a value outside the list you allowed. Model output is untrusted input: treat it exactly like a form submitted by a stranger, and validate it before you use it.
Describe the shape once, with Zod
Say you're sorting support emails. Write the shape as a Zod schema:
import { z } from "zod";
export const Triage = z.object({
category: z.enum(["bug", "billing", "feature_request", "other"]),
urgent: z.boolean(),
summary: z.string().min(1).max(200),
});
export type Triage = z.infer<typeof Triage>;
The schema is the single source of truth: it gives you a TypeScript type for free and a runtime check. If you have a sample JSON reply, the JSON to TypeScript & Zod generator can draft the schema for you.
Ask for exactly that shape
Show the model the fields and the allowed values in the prompt, and ask for JSON only:
const SYSTEM = `You classify support emails.
Reply with JSON only, no other text, matching:
{"category": "bug" | "billing" | "feature_request" | "other", "urgent": boolean, "summary": string (max 200 chars)}`;
Short, explicit examples of the allowed values work better than long explanations.
Pull the JSON out and validate it
Models sometimes wrap JSON in a code fence, so take the text between the first { and the last } before parsing:
function extractJson(text: string): unknown {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) throw new Error("No JSON object in the reply");
return JSON.parse(text.slice(start, end + 1));
}
const parsed = Triage.safeParse(extractJson(replyText));
if (parsed.success) {
console.log(parsed.data.category); // fully typed
} else {
console.log(parsed.error.issues);
}
safeParse never throws; it tells you exactly which field is wrong, which you'll use next.
Retry once with the error
When validation fails, send the model its own reply plus the validation errors and ask it to fix them. One retry fixes most problems; more retries mostly waste tokens.
async function triage(email: string): Promise<Triage> {
const messages: { role: "user" | "assistant"; content: string }[] = [{ role: "user", content: email }];
for (let attempt = 0; attempt < 2; attempt++) {
const { text } = await askModel(SYSTEM, messages); // the function from lesson 1
try {
const result = Triage.safeParse(extractJson(text));
if (result.success) return result.data;
messages.push({ role: "assistant", content: text });
messages.push({ role: "user", content: `That JSON is invalid: ${JSON.stringify(result.error.issues)}. Reply with corrected JSON only.` });
} catch (err) {
messages.push({ role: "assistant", content: text });
messages.push({ role: "user", content: `${(err as Error).message}. Reply with JSON only.` });
}
}
throw new Error("Model did not return valid JSON after a retry");
}
The function either returns a typed Triage or throws. Callers never see half-valid data.
Decide what happens on failure
Throwing is only half the job. Pick a fallback that's safe for your app: put the email in an "other" queue for a human, show a "couldn't classify" badge, or log it and move on. What you must not do is guess.
Many providers also offer a built-in structured output or JSON mode that constrains the reply to a schema. Use it when it's available, and keep the Zod check anyway: it costs microseconds and protects you if the setting changes or you switch providers.
Common mistakes
- Trusting enum values. Without validation, "Billing" or "payment" slips through and breaks a
switch. - Asking for huge nested objects. Smaller schemas fail less. Split big extractions into several calls.
- Retrying forever. Cap retries and have a fallback.
- Using model output in SQL or shell commands. Validated or not, pass it as parameters, never as code.