Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate16 min readUpdated
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
Structured outputs compared: free-text answers vary in format and need fragile parsing, while schema-validated JSON parses into typed data every time.
Show 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.

lib/ticket-schema.ts
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:

triage.ts
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_output can be null. 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 an AnthropicError that lists the failing fields. Print zodOutputFormat(Triage).schema to 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.

From model output to trusted dataThe model returns JSON constrained to your schema; the SDK parses it; your code then checks business rules; a failed check is sent back to the model with the error for one retry, and repeated failure goes to a fallback.JSONtypedModeloutput_config.formatZod schema checktyped data or issuesBusiness rulesyour checksUse the datastore, display, actFallbackhuman review, error

Hover or tap a component to see what it does.

Validating structured output: the schema guarantees shape, your business rules check meaning, one retry feeds the error back to the model, and repeated failure goes to a fallback.
Show 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.

lib/triage-with-rules.ts
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 };
}
triage-batch.ts
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:

rules.test.ts
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.

strict-tool.ts
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 needUse
The final answer as data (classification, extraction, scoring)output_config.format with messages.parse
The model to take actions with validated argumentsTools with strict: true
Prose for a person, with some structurePlain text with format instructions or examples
Both reasoning and a structured resultStructured output with a reasoning string field placed before the answer fields

Common mistakes#

  • Using JSON.parse on 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_output is null on 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

  1. Add a language field (an ISO 639-1 code such as "en") and a business rule that rejects codes not in a list you support.
  2. Write three tickets in different languages and check the field in the output.
  3. Add a unit test for the new rule in rules.test.ts, and run it without any API calls.

Check your understanding

What does a structured output guarantee?
When is message.parsed_output null?
A business rule fails. What makes a retry most likely to succeed?

Key takeaways

  • Define the schema once in Zod: it drives the API's JSON schema, TypeScript types, and runtime validation.
  • Use messages.parse with zodOutputFormat, and handle a null parsed_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: true on tools when their inputs must match the schema exactly.

Next steps#

Finished this article? Track it to see your progress on the roadmap.