← Blog

Typed output from Claude with Zod: stop parsing prose and delete the retry loop

Stop prompting Claude for JSON and parsing the prose. Constrain the output to a Zod schema, validate it client-side, and delete the retry loop.

You ask the model for JSON, add "respond with only valid JSON, no markdown" to the prompt, and it works — until the day it wraps the object in a code fence, or adds a chatty preamble, or invents a field your parser does not expect. So you write a regex to fish the JSON out of the prose, then a retry loop for when that fails, and now your "structured" output is held together with string surgery. There is a better way, and it removes the whole category of problem.

Constrain the shape server-side, do not beg for it

The API can constrain a response to a schema at generation time, so the model cannot return anything that does not match. You hand it a Zod schema, and the SDK constrains the output and validates the result against that same schema before handing it back — typed:

export async function generateObject<S extends z.ZodType>(
  opts: GenerateOptions & { schema: S },
): Promise<GenerateObjectResult<z.infer<S>>> {
  const response = await anthropic().messages.parse({
    model: opts.model ?? DEFAULT_MODEL,
    max_tokens: opts.maxTokens ?? 16_000,
    messages: opts.messages,
    output_config: { format: zodOutputFormat(opts.schema) },
  });
  assertCompleted(response);
  if (response.parsed_output == null) {
    throw new Error("Structured output parsing failed — no parsed_output.");
  }
  return { data: response.parsed_output, usage: usageFromMessage(response.usage) };
}

There is no "respond with JSON" instruction anywhere, because the constraint is not a request — it is enforced. The return type is z.infer<S>, so the caller gets a fully typed object, not an any you have to cast and hope about.

One schema, two guarantees

The single Zod schema does two jobs. Server-side, it constrains what the model is allowed to emit, so the shape is guaranteed before a byte crosses the wire. Client-side, the SDK parses the result against the same schema, so field types, enums, and ranges are validated at runtime — the guarantees a plain TypeScript type cannot give you because types vanish at compile time. Define the shape once; get both the generation constraint and the runtime check from it.

The failure modes this removes — and the two it does not

What goes away: the extract-the-JSON-from-the-prose regex, the "it added a markdown fence again" bug, and the retry-on-parse-error loop. Those are gone because malformed output is no longer possible, not merely less likely.

What does not go away, and still needs handling:

  • Refusals. A constrained request can still be declined; the stop reason is refusal, and there is no object to parse. Check for it explicitly rather than treating a null result as a parse bug.
  • Truncation. If the response hits max_tokens mid-object, the output is incomplete and invalid. A big or deeply nested schema makes this more likely, so size max_tokens to the schema — this is the same discipline the kit uses everywhere it calls the model, and it pairs naturally with the per-request cost metering that tells you how large your responses actually run.
  • Null output. Guard parsed_output == null and fail loudly; a silent null is worse than an exception.

A short helper that asserts the stop reason and guards the null turns "usually returns JSON" into "returns a typed object or throws" — which is the property you actually want to build on.

Where it pays off

Anywhere you turn language into a record: extracting fields from an email, classifying a ticket into an enum, pulling structured arguments for a tool call. Shipwright — the Next.js and Claude starter kit this blog documents — uses this one generateObject helper for all of them, including the LLM-judge grader in its eval harness, so every structured call in the codebase has the same typed, validated contract. You can see typed model output in the live demo.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →