Structured JSON Outputs from Claude with Type-Safe Zod Contracts
Your pipeline expected { "category": "billing" } and Claude returned "Sure! This looks like a billing issue, here's the JSON:" wrapped in a markdown fence. So you reached for a regex to rip the object out of the prose, and that regex has been the source of every 2am page since. An LLM that returns prose can't drive a system. The job is to make the model emit a typed object and to validate that object at the boundary — before it ever touches your code.
Core Architectural Concepts & Trade-offs
The reliable way to get JSON out of Claude is not "please respond in JSON" — it's tool use. Define a tool whose input_schema is the shape you want, then set tool_choice to force that tool. The model is now constrained to produce arguments matching your JSON Schema, and the API hands you the object as the tool call's input — no fences, no preamble, no parsing. You've turned a generation problem into a function-signature problem.
But a schema the model tried to satisfy is not a guarantee. The JSON Schema you pass to the API constrains structure, but it can't express every invariant your domain needs — a severity that must be 1–5, a summary under 120 characters, an enum that must be exhaustive. So you keep a second contract on your side of the wire: a Zod schema that is the real source of truth. z.infer gives you the static type for free, and .parse() enforces the runtime invariants the moment data crosses the boundary. One definition, both a compile-time type and a runtime gate.
The trade-off is what to do on a validation miss, and the answer is rarely "crash." A failed parse is signal: feed the validation error back to the model as a tool_result and ask it to correct the offending field. One repair round-trip fixes the overwhelming majority of misses cheaply. Bound the retries — two or three — so a genuinely impossible request fails fast instead of looping. This is the same discipline as the output-budget guard from Lesson 1: validate at the edge, fail loudly, never let malformed data leak inward.
Note the model-routing angle: forced-tool JSON extraction is a well-structured, low-creativity task. You don't need Opus 4.8 for it — Sonnet 4.6 or even Haiku 4.5 hits the schema reliably at a fraction of the cost and latency. Reserve the flagship for the reasoning, and let a cheaper model do the structured plumbing.
A Validated Extraction
One Zod schema is the contract. The tool forces the shape; Zod enforces the invariants the JSON Schema can't.
// One definition → a compile-time type AND a runtime gate.
import Anthropic from "@anthropic-ai/sdk"
import { z } from "zod"
const Ticket = z.object({
category: z.enum(["billing", "bug", "feature", "other"]),
severity: z.number().int().min(1).max(5),
summary: z.string().max(120),
})
type Ticket = z.infer<typeof Ticket>
const anthropic = new Anthropic()
export async function classify(text: string): Promise<Ticket> {
const res = await anthropic.messages.create({
model: "claude-haiku-4-5", // structured plumbing — cheap tier
max_tokens: 512,
tool_choice: { type: "tool", name: "emit_ticket" },
tools: [{
name: "emit_ticket",
description: "Return the structured ticket classification.",
input_schema: {
type: "object",
properties: {
category: { type: "string", enum: ["billing","bug","feature","other"] },
severity: { type: "integer", minimum: 1, maximum: 5 },
summary: { type: "string" },
},
required: ["category","severity","summary"],
},
}],
messages: [{ role: "user", content: text }],
})
const block = res.content.find((b) => b.type === "tool_use")
if (block?.type !== "tool_use") throw new Error("model returned no tool call")
// The schema is the contract, not a suggestion. Throws → caller can repair.
return Ticket.parse(block.input)
}Wrap the call in a bounded retry that re-sends Zod's error message as a correction prompt, and the rare miss self-heals in one round-trip instead of paging you.
The Validation Boundary
Structured output is what turns a chat model into a component. Next, the full mechanics behind that tool_use block: tool use & function calling. Or browse the full roadmap.