Structured Outputs with Zod: Typed JSON from LLMs in TypeScript
Get typed, schema-valid JSON from Claude with Zod and messages.parse, validate business rules in code, retry with feedback, and use strict tools for tool inputs.
On this page· Free text versus a schema
Why this matters
As soon as code, not a person, reads the model's answer, prose becomes a liability. A dashboard needs a category, a priority, and a list of fields, every time, in the same shape. Structured outputs constrain the model to a JSON schema you provide, so the response always parses. With Zod, the same schema also gives you TypeScript types and runtime validation, so one definition covers the model, the compiler, and your checks.
Free text versus a schema#
Free text
Sure! Jane Doe (jane@acme.co) reached out about the
Enterprise plan. She's interested in the API and the SDKs,
and yes, she'd love a demo next week.- • Wording and order change between calls
- • You parse it with brittle regular expressions
- • “Enterprise” vs “enterprise” vs “Ent.”
- • A missing field fails silently downstream
Schema-validated JSON
const Lead = z.object({
name: z.string(),
email: z.email(),
plan: z.enum(["free", "pro", "enterprise"]),
interests: z.array(z.string()),
demo_requested: z.boolean(),
});{
"name": "Jane Doe",
"email": "jane@acme.co",
"plan": "enterprise",
"interests": ["api", "sdks"],
"demo_requested": true
}- • Same keys and types on every call
- • Enums constrain values to what you accept
- • A mismatch is an explicit error, not a guess
Show text descriptionHide text description
The left panel shows the same lead-extraction request answered as free text: a friendly sentence whose wording, field order, and casing can change on every call, so your code has to guess where the email or plan name is. The right panel shows the request made with a Zod schema through structured outputs: the model must return JSON matching the schema, the SDK validates it, and your code receives a typed object with a known shape.
- Free text: format varies between runs, needs regular expressions, fields can be missing, values are unnormalized.
- Structured output: same keys every time, enums constrain values, parsing failures are explicit, types flow into your code.
Asking "reply in JSON" in the prompt usually works, which is the problem: usually means occasional prose before the JSON, a missing field, or a trailing comment, and those bugs show up in production. With structured outputs, the API constrains generation to your schema, so the response is valid JSON with the required fields and types.
Define the schema once#
Zod schemas double as documentation for the model: field names, enums, and .describe() text all reach it as part of the JSON schema.
import { z } from "zod";
export const Triage = z.object({
category: z.enum(["billing", "bug", "account", "feature_request", "other"]),
priority: z.enum(["low", "normal", "high", "urgent"]).describe("urgent only when the customer cannot work at all or money is being lost"),
summary: z.string().describe("One sentence, written for a support engineer"),
customerSentiment: z.enum(["positive", "neutral", "frustrated", "angry"]),
orderIds: z.array(z.string()).describe("Order IDs mentioned, without the # sign; empty if none"),
needsHuman: z.boolean().describe("true when the request needs a refund, legal answer, or account change"),
});
export type Triage = z.infer<typeof Triage>;Parse a response into typed data#
messages.parse sends the schema as output_config.format and validates the reply with Zod on the way back. parsed_output is fully typed:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { Triage } from "./lib/ticket-schema.ts";
const client = new Anthropic();
const ticket = `Hi, I've been charged twice for order #A-1043 and again for #A-1051 this week.
This is the third time I'm writing. Please fix it today or I'm cancelling.`;
const message = await client.messages.parse({
model: "claude-opus-5-5",
max_tokens: 2048,
output_config: { effort: "low", format: zodOutputFormat(Triage) },
system: "You triage customer support tickets for a billing product.",
messages: [{ role: "user", content: `<ticket>\n${ticket}\n</ticket>` }],
});
const triage = message.parsed_output; // Triage | null
if (!triage) throw new Error(`No structured output (stop_reason: ${message.stop_reason})`);
console.log(triage.category, triage.priority, triage.orderIds);
// billing high [ 'A-1043', 'A-1051' ] (typical output)A few details that matter:
effort: "low"suits extraction: the task is narrow, and lower effort is faster and cheaper.parsed_outputcan benull. If the model refuses (stop_reason: "refusal") or runs out of tokens ("max_tokens"), there is no complete JSON to parse. Always check before use.- Some rules are enforced by Zod, not by the API. The SDK sends the core structure (objects, field types, required fields, arrays) as hard constraints. Other keywords, such as string lengths, numeric ranges and, in current SDK versions, enum values, go into the field's description, so the model sees them, and Zod checks them when parsing. A violation makes
parse()throw anAnthropicErrorthat lists the failing fields. PrintzodOutputFormat(Triage).schemato see exactly what is sent.
Valid shape is not a correct answer#
A schema guarantees shape. It cannot know that an order ID must exist in your database, or that a "low" priority on a ticket mentioning a lawsuit is wrong. Those are business rules, and they belong in code after parsing.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Structured outputs remove parsing failures. They do not remove wrong answers, so a second layer of checks in code is still needed.
- Model: Structured outputs constrain generation to your JSON Schema, so the response always parses.
- Zod schema check: The Zod schema validates the JSON (via messages.parse or safeParse) and yields typed data, or a list of issues to send back to the model.
- Business rules: Valid JSON can still be wrong: a date in the past, a total that does not add up, an ID that does not exist. Check what the schema cannot.
- Use the data: Only data that passed both checks reaches your database or triggers actions.
- Fallback: After a retry fails, stop and route to a person or a clear error. Do not loop.
The pattern: parse, check rules, and on failure retry once with the specific errors in the conversation, so the model can correct itself. If the second attempt still fails, fall back to something safe, such as a human queue.
This version calls create() with the same format and runs safeParse itself. parse() throws on a schema violation and discards the reply, while safeParse keeps the reply, so schema problems and business-rule problems can both go back to the model the same way.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { Triage } from "./ticket-schema.ts";
const client = new Anthropic();
const ORDER_ID = /^[A-Z]-\d{4}$/;
// Business rules the schema cannot express. Returns human-readable problems.
export function checkRules(t: Triage, ticket: string, knownOrders: Set<string>): string[] {
const problems: string[] = [];
for (const id of t.orderIds) {
if (!ORDER_ID.test(id)) problems.push(`orderIds: "${id}" is not in the format X-0000`);
else if (!knownOrders.has(id)) problems.push(`orderIds: "${id}" does not exist for this customer`);
}
if (/\b(lawyer|legal action|chargeback)\b/i.test(ticket) && !t.needsHuman) {
problems.push("needsHuman: tickets that mention legal action or chargebacks always need a human");
}
return problems;
}
export type TriageResult = { ok: true; triage: Triage; attempts: number } | { ok: false; reason: string; attempts: number };
export async function triageTicket(ticket: string, knownOrders: Set<string>): Promise<TriageResult> {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: `<ticket>\n${ticket}\n</ticket>` }];
for (let attempt = 1; attempt <= 2; attempt++) {
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 2048,
output_config: { effort: "low", format: zodOutputFormat(Triage) },
system: "You triage customer support tickets for a billing product.",
messages,
});
if (message.stop_reason !== "end_turn") {
return { ok: false, reason: `no complete output (stop_reason: ${message.stop_reason})`, attempts: attempt };
}
const text = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
const parsed = Triage.safeParse(JSON.parse(text));
const problems = parsed.success
? checkRules(parsed.data, ticket, knownOrders)
: parsed.error.issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`);
if (parsed.success && problems.length === 0) return { ok: true, triage: parsed.data, attempts: attempt };
// Show the model its own answer and exactly what was wrong with it.
messages.push(
{ role: "assistant", content: message.content },
{ role: "user", content: `Your triage has problems:\n- ${problems.join("\n- ")}\nReturn a corrected triage.` },
);
}
return { ok: false, reason: "business rules still failing after retry", attempts: 2 };
}import { triageTicket } from "./lib/triage-with-rules.ts";
const knownOrders = new Set(["A-1043", "A-1051", "B-2210"]);
const tickets = [
"Charged twice for #A-1043, please refund.",
"My bank says I should file a chargeback for order B-2210 if you don't answer.",
"Feature idea: export invoices as CSV.",
];
for (const ticket of tickets) {
const result = await triageTicket(ticket, knownOrders);
if (result.ok) console.log(result.attempts, result.triage.category, result.triage.priority, result.triage.needsHuman);
else console.log("→ human queue:", result.reason); // the safe fallback
}Because checkRules is a plain function, test it without calling the model at all:
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkRules } from "./lib/triage-with-rules.ts";
import type { Triage } from "./lib/ticket-schema.ts";
const base: Triage = {
category: "billing", priority: "high", summary: "Double charge", customerSentiment: "frustrated",
orderIds: ["A-1043"], needsHuman: false,
};
const known = new Set(["A-1043"]);
test("accepts a valid triage", () => {
assert.deepEqual(checkRules(base, "Charged twice", known), []);
});
test("rejects unknown and malformed order IDs", () => {
const problems = checkRules({ ...base, orderIds: ["A-9999", "1043"] }, "x", known);
assert.equal(problems.length, 2);
});
test("requires a human for chargebacks", () => {
assert.match(checkRules(base, "I will file a chargeback", known)[0] ?? "", /needsHuman/);
});Run it with node --test rules.test.ts.
Strict tools: structured inputs#
Structured outputs shape the model's final answer. When the model calls one of your tools instead, the same guarantee is available for the tool's input: add strict: true to the tool definition, and every tool_use input matches its input_schema. That matters when tool inputs flow straight into a database query or an API call.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 2048,
tools: [
{
name: "create_refund",
description: "Create a refund request for a single order. Use only after the customer confirmed the order ID.",
strict: true,
input_schema: {
type: "object",
properties: {
orderId: { type: "string", description: "Order ID such as A-1043" },
reason: { type: "string", enum: ["duplicate_charge", "not_delivered", "other"] },
},
required: ["orderId", "reason"],
additionalProperties: false,
},
},
],
messages: [{ role: "user", content: "Yes, order A-1043 was the duplicate. Please refund it." }],
});
for (const block of message.content) {
if (block.type === "tool_use") console.log(block.name, block.input); // input matches the schema
}The next article covers the full loop of running the tool and returning results.
Which mechanism to use#
| You need | Use |
|---|---|
| The final answer as data (classification, extraction, scoring) | output_config.format with messages.parse |
| The model to take actions with validated arguments | Tools with strict: true |
| Prose for a person, with some structure | Plain text with format instructions or examples |
| Both reasoning and a structured result | Structured output with a reasoning string field placed before the answer fields |
Common mistakes#
- Using
JSON.parseon prose. "Reply only with JSON" plus a regex to strip code fences breaks in production. Use structured outputs. - Treating a valid schema as a correct answer. Check business rules in code; the schema cannot know your data.
- Retrying blindly. A retry without the specific errors usually repeats the mistake. Include what was wrong.
- Ignoring
null.parsed_outputisnullon refusals and truncated responses. Handle it explicitly. - Huge, deeply optional schemas. Each optional field is a decision the model must make. Keep schemas small and required-by-default.
Try it
Extend the triage
- Add a
languagefield (an ISO 639-1 code such as"en") and a business rule that rejects codes not in a list you support. - Write three tickets in different languages and check the field in the output.
- Add a unit test for the new rule in
rules.test.ts, and run it without any API calls.
Check your understanding
Key takeaways
- Define the schema once in Zod: it drives the API's JSON schema, TypeScript types, and runtime validation.
- Use
messages.parsewithzodOutputFormat, and handle anullparsed_output. - A valid shape is not a correct answer: check business rules in code, retry once with specific feedback, then fall back safely.
- Use
strict: trueon tools when their inputs must match the schema exactly.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Prompt and Context Engineering for LLM Apps (with TypeScript)Write prompts that work in production: clear instructions, examples, XML-tagged context, and request assembly that is ordered for caching and safe with untrusted data.
- Tool Use (Function Calling) with Claude in TypeScriptLet the model call your functions: define tools, run the tool-use loop by hand, handle errors and parallel calls, then use the SDK tool runner with Zod-typed tools.