NewValidate LLM output with Zod and retry when it's wrong →
w3/w3-tutorials

Validate LLM output with Zod and retry when it's wrong

Treat model replies like user input: check them against a schema and send the exact errors back for a corrected answer.

Updated Nov 8, 20263 min readIntermediatenode 22.22 · zod 4.6

What you'll build

A function that turns a customer message into a typed support ticket, and asks the model to correct itself when its answer is wrong. It works with any provider, because the model call is passed in as a function.

Models usually return the shape you asked for, but "usually" isn't good enough for code that writes to a database. Treat model output like user input: validate it first.

terminal
npm install zod

1. Define the shape once

A Zod schema gives you runtime validation and a TypeScript type from one definition. Zod 4 can also produce JSON Schema, so the prompt and the validator can't drift apart.

ticket.ts
import { z } from "zod";

export const Ticket = z.object({
  category: z.enum(["billing", "bug", "question"]),
  priority: z.number().int().min(1).max(3),
  summary: z.string().min(5).max(120),
});

export type Ticket = z.infer<typeof Ticket>;
export const ticketJsonSchema = z.toJSONSchema(Ticket);

2. Handle a bad reply

A reply can fail in two ways: it isn't JSON at all, or it's JSON with the wrong fields. This helper turns both into a normal validation result, so the calling code only has one case to handle.

ticket.ts
function parseReply(reply: string) {
  try {
    return Ticket.safeParse(JSON.parse(reply));
  } catch {
    return Ticket.safeParse(undefined);
  }
}

3. Retry with the error message

Don't send the same prompt again. Tell the model exactly what was wrong. z.prettifyError produces text that's easy for it to act on.

ticket.ts
type CallModel = (prompt: string) => Promise<string>;

export async function extractTicket(message: string, callModel: CallModel, maxAttempts = 3): Promise<Ticket> {
  let prompt = `Return only JSON matching this schema:\n${JSON.stringify(ticketJsonSchema)}\n\nMessage:\n${message}`;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const reply = await callModel(prompt);
    const result = parseReply(reply);
    if (result.success) return result.data;

    prompt += `\n\nYour last reply was invalid:\n${reply}\n\nProblems:\n${z.prettifyError(result.error)}\n\nReturn corrected JSON only.`;
  }

  throw new Error(`No valid ticket after ${maxAttempts} attempts`);
}

4. Try it without an API key

A fake model that fails once proves the retry path works, and it makes a good unit test:

demo.ts
const replies = [
  '{"category":"refund","priority":5,"summary":"Refund"}',
  '{"category":"billing","priority":2,"summary":"Customer was charged twice"}',
];

const ticket = await extractTicket("I was charged twice!", async () => replies.shift()!);
console.log(ticket);

The first reply fails validation, and the model receives this feedback in its next prompt:

feedback sent to the model
✖ Invalid option: expected one of "billing"|"bug"|"question"
  → at category
✖ Too big: expected number to be <=3
  → at priority

The second reply passes, and ticket is fully typed.

Next steps

  • Replace the fake with a real call to your provider's SDK.
  • If your provider supports structured outputs, pass it ticketJsonSchema and keep the Zod check as a second line of defence.
  • Log every failed attempt. A rise in retries is often the first sign that a prompt or model change went wrong.