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_tokensmid-object, the output is incomplete and invalid. A big or deeply nested schema makes this more likely, so sizemax_tokensto 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 == nulland 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.