Stop Trusting LLM JSON: Validate AI Outputs with Zod in 5 Minutes

typescript dev.to

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 of 5, especially across model versions
  • Missing optional-ish fields — the model decides metadata wasn'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}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  1. safeParse, not parse — LLM failures are an expected case, not an exception. Handle them like one.
  2. 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 safeParse and feed validation errors back into a retry prompt.
  • z.infer keeps 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.

Source: dev.to

arrow_back Back to Tutorials