← 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.