Reliable JSON from LLMs: schema validation in TypeScript

How to get reliable JSON from LLMs in TypeScript: structured output modes, Zod schema validation, error feedback retries and a safe fallback path.

By Hamza Ghouri

The moment an LLM feature moves past a chat box, you need structured data. A category, a priority, a list of extracted fields, a set of documents your code can loop over. Getting reliable JSON from LLMs is the unglamorous foundation under most useful AI features, and it is mostly a TypeScript problem: define the shape, enforce it, and decide what happens when the model misses.

Here is the pattern I use in Node.js backends. It is boring on purpose.

Why "just ask for JSON" isn't enough

If you ask a model to "respond in JSON", it will most of the time. The failures are the interesting part:

  • The JSON is wrapped in a Markdown code fence or a friendly sentence ("Here is the result:").
  • A field is missing, renamed or nested one level deeper than you expected.
  • An enum value is close but different: "High" where you wanted "high", or a category you never listed.
  • A string you wanted short comes back as three paragraphs.
  • On a long output, the response gets cut off and the JSON never closes.

Each of these breaks JSON.parse or, worse, parses fine and breaks something three functions later. The goal is to catch all of them at one boundary, right where the model's text enters your system.

Use structured output, then validate anyway

Many model APIs now offer a JSON mode or schema-constrained output, where you pass a JSON Schema and the API constrains generation to match it. If your provider supports it, turn it on. It removes most syntax failures and a good share of shape failures.

I still validate every response in my own code, for three reasons. Constrained output guarantees shape, and it says nothing about meaning: an empty summary string is perfectly valid JSON. Support and behavior differ across providers and versions, and you may switch providers later. And your schema usually encodes rules that are awkward to express in a provider's schema dialect, like "at most five tags" or "summary under 200 characters".

So the provider feature is a convenience. The schema in your codebase is the contract.

Define the contract with Zod

Zod gives you one definition that does two jobs: runtime validation and the TypeScript type. That matters, because the most common bug in this area is a type that says one thing and a runtime that accepts another.

Here is a complete example: classifying a support ticket, with validation and a retry loop that feeds the errors back to the model.

import { z } from "zod";
import { callModel } from "./llm"; // your thin wrapper around the model API

const Ticket = z.object({
  category: z.enum(["billing", "bug", "feature_request", "other"]),
  priority: z.enum(["low", "medium", "high"]),
  summary: z.string().min(1).max(200),
  tags: z.array(z.string()).max(5),
});
export type Ticket = z.infer<typeof Ticket>;

function extractJson(text: string): unknown {
  // Models sometimes wrap JSON in prose or code fences; grab the outermost object.
  const start = text.indexOf("{");
  const end = text.lastIndexOf("}");
  if (start === -1 || end <= start) throw new Error("no JSON object found");
  return JSON.parse(text.slice(start, end + 1));
}

function buildPrompt(ticket: string, feedback: string): string {
  return [
    "Classify this support ticket. Reply with only a JSON object with keys:",
    'category ("billing" | "bug" | "feature_request" | "other"),',
    'priority ("low" | "medium" | "high"), summary (string, max 200 chars), tags (max 5 strings).',
    feedback && `Your previous reply was rejected for these reasons:\n${feedback}`,
    `Ticket:\n${ticket}`,
  ]
    .filter(Boolean)
    .join("\n\n");
}

export async function classifyTicket(ticket: string, maxAttempts = 3): Promise<Ticket> {
  let feedback = "";
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const raw = await callModel(buildPrompt(ticket, feedback));
    try {
      const result = Ticket.safeParse(extractJson(raw));
      if (result.success) return result.data;
      feedback = result.error.issues
        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
        .join("\n");
    } catch (err) {
      feedback = `Reply was not valid JSON: ${(err as Error).message}`;
    }
  }
  throw new Error(`Ticket classification failed validation after ${maxAttempts} attempts`);
}

A few details in there are deliberate.

safeParse over parse. I want the issues as data so I can send them back to the model, and I don't want control flow driven by exceptions for an expected outcome.

Error feedback on retry. A blind retry asks the same question and often gets the same mistake. Telling the model exactly which field failed and why fixes most failures on the second attempt.

A hard cap. Three attempts, then a real error. Unbounded retries turn one bad input into a cost problem. If you are already wrapping the model call with network retries, keep the two loops separate: one handles transport failures, the other handles content failures. I cover the transport side in handling LLM API rate limits, retries and timeouts.

Keep schemas small and flat

The smaller the shape, the more reliable the output. A few rules I follow:

  • Prefer enums to free text wherever the downstream code branches on the value.
  • Avoid deep nesting. Two levels is usually plenty. If you need a big tree, generate it in steps.
  • Name keys the way a person would. dueDate beats dt_due. The model reads the key names as instructions.
  • Make optional fields explicit. Decide whether a missing value is null, an empty array or an omitted key, and say so in the prompt and the schema.
  • Ask for one object per call when the list could be long. Long arrays are where truncation bites.

If one schema keeps failing on the same field, that is a design signal. Split the task, simplify the shape or move that decision into code. Splitting work across calls has its own trade-offs, which I walk through in prompt chaining vs one giant prompt.

Validation is only the first gate

A parsed, schema-valid object can still be wrong: the right shape with the wrong facts. Schema validation tells you the output is usable by your code. Whether it is correct, consistent with its inputs and safe to show a user is a second set of checks. I treat those as their own layer in how to validate LLM output before it reaches users.

Where I'd start

Turn on structured output if your model API has it. Define every LLM response as a Zod schema and derive the type from it. Route every call through one function that extracts, validates with safeParse, retries with the issues as feedback and gives up after a fixed number of attempts. Then log the failures, because the fields that fail most often tell you exactly where to simplify.

Want this kind of work on your product?

Get in touch