Claude Structured Outputs in TypeScript with Zod (2026)
June 7, 2026
Structured outputs are generally available on the Claude API: set output_config.format to a JSON schema and Claude's response is guaranteed to parse. In TypeScript, zodOutputFormat() plus client.messages.parse() returns typed, Zod-validated data. This guide builds a complete extraction service, including refusal and truncation handling.
TL;DR
You will build a typed support-ticket triage service on @anthropic-ai/sdk 0.102.0 and Zod 4.4.3 that turns messy emails into schema-guaranteed JSON — no JSON.parse() retries, no "please respond with valid JSON" prompt incantations. You will use both halves of Claude's structured outputs feature: JSON outputs (output_config.format) for the response shape and strict tool use (strict: true) for validated tool inputs.1 Along the way you will handle the two cases where output can still deviate from your schema (refusal and max_tokens stop reasons), inspect the exact schema the SDK sends to Claude, and verify the failure-handling code offline with node:test — without spending a single API token. Budget about 30 minutes.
What you'll learn
- How Claude structured outputs work: grammar-constrained decoding, the
output_config.formatparameter, and which models support it - Defining one Zod schema that drives the API request, response validation, and your TypeScript types
- The happy path:
client.messages.parse()withzodOutputFormat() - What the SDK actually sends: schema transformation, and why
enumandpatternare enforced locally by the Zod helper - Production-grade failure handling for
refusalandmax_tokensstop reasons with a typed result union - Strict tool use: guaranteeing tool-call inputs with
strict: true - Testing the guard logic offline with
node:testfixtures - Grammar compilation, caching, token costs, and schema complexity limits
- Migrating from JSON-mode prompting, tool-trick extraction, and the beta
output_formatparameter
Which Claude models support structured outputs?
Structured outputs are generally available on the Claude API for Claude Opus 4.8, Claude Mythos Preview, Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, Claude Sonnet 4.5, Claude Opus 4.5, and Claude Haiku 4.5.1 On Amazon Bedrock the feature is GA for Claude Opus 4.6, Sonnet 4.6, Sonnet 4.5, Opus 4.5, and Haiku 4.5 (Opus 4.7 and Mythos Preview are additionally available there through the newer Claude in Amazon Bedrock endpoint); on Vertex AI it is GA for the same list as the first-party API; on Microsoft Foundry it is in beta.1 This tutorial uses claude-sonnet-4-6 ($3 per million input tokens, $15 per million output tokens2) — swap in claude-haiku-4-5 ($1/$5 per MTok2) for high-volume extraction.
Under the hood, the API compiles your JSON schema into a grammar and constrains token sampling during inference — the model cannot emit a token that would violate the schema's structure.1 That is fundamentally stronger than prompting for JSON, which merely asks nicely.
Prerequisites
- Node.js 24 LTS (Active LTS, supported through April 20283)
- An Anthropic API key with access to Claude Sonnet 4.6 (
ANTHROPIC_API_KEY) - Familiarity with
async/awaitand basic Zod usage
All versions are pinned: @anthropic-ai/sdk 0.102.0, zod 4.4.3, typescript 6.0.3, tsx 4.22.4, @types/node 24.13.1.4
Step 1 — Scaffold the project
Create the project and install the pinned dependencies:
mkdir claude-structured-outputs-demo && cd claude-structured-outputs-demo
npm init -y
npm install @anthropic-ai/sdk@0.102.0 zod@4.4.3
npm install -D typescript@6.0.3 tsx@4.22.4 @types/node@24.13.1
The SDK declares Zod as an optional peer dependency with the range ^3.25.0 || ^4.0.0, so Zod 4 is fully supported.4
Replace package.json with:
{
"name": "claude-structured-outputs-demo",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test src/guards.test.ts",
"triage": "node --env-file=.env --import tsx src/index.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "0.102.0",
"zod": "4.4.3"
},
"devDependencies": {
"@types/node": "24.13.1",
"tsx": "4.22.4",
"typescript": "6.0.3"
}
}
"type": "module" matters: this project uses top-level await and ESM imports, and the strict compiler settings below will refuse ESM syntax in a CommonJS package.
Add tsconfig.json:
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Finally, create .env with your key (never commit this file):
echo "ANTHROPIC_API_KEY=your-key-here" > .env
Step 2 — Define one Zod schema as the single source of truth
The schema drives three things at once: the JSON schema sent to the API, runtime validation of the response, and the inferred TypeScript type. Create src/schema.ts:
import { z } from "zod";
export const TicketTriage = z.object({
category: z.enum(["billing", "bug", "account", "feature_request", "other"]),
priority: z.enum(["low", "normal", "high", "urgent"]),
summary: z.string().describe("One-sentence summary of the issue"),
customerEmail: z.email().describe("Email address of the requester"),
mentionsOutage: z.boolean().describe("True if the ticket reports a full outage"),
followUpActions: z
.array(z.string())
.describe("Concrete next steps for the support agent"),
orderId: z
.string()
.optional()
.describe("Order id like ORD-12345, if the ticket references one"),
});
export type TicketTriage = z.infer<typeof TicketTriage>;
Two design constraints come straight from the API's JSON Schema support. Structured outputs accept all basic types, enum (primitive values only), const, anyOf/allOf (allOf combined with $ref is not supported), internal $ref/$defs, default, required, the string formats date-time, time, date, duration, email, hostname, uri, ipv4, ipv6, and uuid, and array minItems of 0 or 1. They do not accept recursive schemas, external $ref, numerical constraints like minimum/maximum, string length constraints (minLength/maxLength), other array constraints, or additionalProperties set to anything but false — unsupported features in a raw schema produce a 400 error.1
Keep optional() fields rare and deliberate: each optional parameter counts toward a request-wide budget of 24 across all strict tool schemas and JSON output schemas combined, and the docs note each one roughly doubles a portion of the grammar's state space.1
Step 3 — The happy path: messages.parse() with zodOutputFormat()
How do you get guaranteed, typed JSON from Claude in TypeScript? Pass zodOutputFormat(schema) as output_config.format to client.messages.parse(), and read parsed_output — already validated by Zod and typed as TicketTriage. Create src/extract.ts:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { TicketTriage } from "./schema.ts";
const client = new Anthropic();
export async function triageTicket(ticket: string): Promise<TicketTriage> {
const message = await client.messages.parse({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: [
{ role: "user", content: `Triage this support ticket:\n\n${ticket}` },
],
output_config: {
format: zodOutputFormat(TicketTriage),
},
});
if (message.parsed_output === null) {
throw new Error(
`No structured output returned (stop_reason: ${message.stop_reason})`,
);
}
return message.parsed_output;
}
The raw JSON also remains available as text: with JSON outputs, Claude's response is a normal text block containing valid JSON, so message.content[0] still carries the string form.1 If you prefer raw JSON Schema over Zod, the SDK ships a parallel helper, jsonSchemaOutputFormat() from @anthropic-ai/sdk/helpers/json-schema — pass an inline literal as const and parsed_output is even inferred at compile time.5
One behavior to internalize before production: when parsing or validation fails, parse() does not return a partial result — it throws. In SDK 0.102.0, the parse step runs JSON.parse and then schema.safeParse, and wraps either failure in an AnthropicError whose message starts with "Failed to parse structured output" — listing up to five Zod issues when it is the schema validation that failed.5 The Message object — including stop_reason, the raw text, and usage — is gone by the time you catch. Step 5 fixes that.
Step 4 — Inspect what Claude actually receives
Trust, but print. The Zod helper transforms your schema before sending. Create show-schema.ts at the project root:
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { TicketTriage } from "./src/schema.ts";
const format = zodOutputFormat(TicketTriage);
console.log(
JSON.stringify({ type: format.type, schema: format.schema }, null, 2),
);
Run it:
node --import tsx show-schema.ts
Abbreviated output (SDK 0.102.0):
{
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "{enum: [\"billing\",\"bug\",\"account\",\"feature_request\",\"other\"]}"
},
"customerEmail": {
"type": "string",
"description": "Email address of the requester\n\n{pattern: \"...\"}",
"format": "email"
},
"followUpActions": {
"type": "array",
"description": "Concrete next steps for the support agent",
"items": { "$ref": "#/$defs/__schema0" }
}
},
"additionalProperties": false,
"required": ["category", "priority", "summary", "customerEmail", "mentionsOutage", "followUpActions"]
}
}
Three observations, all verifiable on your machine:
- The helper is conservative. The documented transformation removes unsupported constraints, folds them into field descriptions, adds
additionalProperties: falseto every object, and filters string formats to the supported list.1 In 0.102.0 that conservatism extends further than you might expect: the Zod helper demotes evenenumandpattern— both supported by the API in raw schemas — into description text. For this schema, what survived in grammar-enforced positions wastype,format, object structure, and requiredness. - Local validation closes the gap. The SDK validates the response against your original Zod schema, with every constraint intact.1 A
categoryoutside the enum would sail through the server-side grammar but fail the localsafeParse, surfacing as theAnthropicErrorfrom Step 3. The guarantee you code against is the Zod schema, not the transmitted grammar. - If you need the grammar itself to enforce
enum, send a raw schema: eitheroutput_config: { format: { type: "json_schema", schema: {...} } }onmessages.create()exactly as the official quick start does,1 orjsonSchemaOutputFormat(schema, { transform: false })to skip the SDK transformation entirely.5
This is also where property ordering becomes predictable: in the output, required properties appear first in schema order, then optional ones in schema order — our optional orderId always arrives last, regardless of where Claude "thinks of it".1
Step 5 — Production path: stop_reason guards with a typed result union
Two documented cases can still produce output that does not match your schema, both arriving with HTTP 200. A safety refusal sets stop_reason: "refusal" (you are billed for generated tokens, and the refusal text takes precedence over the schema). Hitting the token ceiling sets stop_reason: "max_tokens" and may cut the JSON mid-field.1 Because parse() throws away the Message when validation fails, production code that wants to log usage, branch on the stop reason, or implement retry policy should drop down to messages.create() and own the parsing. Create src/extract-manual.ts:
import Anthropic from "@anthropic-ai/sdk";
import type { Message, TextBlock } from "@anthropic-ai/sdk/resources/messages";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { TicketTriage } from "./schema.ts";
const client = new Anthropic();
export type TriageResult =
| { ok: true; data: TicketTriage }
| {
ok: false;
reason: "refusal" | "truncated" | "invalid_json" | "schema_mismatch";
detail: string;
};
export function parseTriageMessage(message: Message): TriageResult {
if (message.stop_reason === "refusal") {
return {
ok: false,
reason: "refusal",
detail:
"Claude declined the request. The refusal text takes precedence over the schema, so do not parse it.",
};
}
if (message.stop_reason === "max_tokens") {
return {
ok: false,
reason: "truncated",
detail:
"Response hit max_tokens and the JSON may be cut off. Retry with a higher max_tokens.",
};
}
const text =
message.content.find((b): b is TextBlock => b.type === "text")?.text ?? "";
let candidate: unknown;
try {
candidate = JSON.parse(text);
} catch (err) {
return { ok: false, reason: "invalid_json", detail: String(err) };
}
const result = TicketTriage.safeParse(candidate);
if (!result.success) {
return {
ok: false,
reason: "schema_mismatch",
detail: z.prettifyError(result.error),
};
}
return { ok: true, data: result.data };
}
export async function triageTicketSafe(ticket: string): Promise<TriageResult> {
const message = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: [
{ role: "user", content: `Triage this support ticket:\n\n${ticket}` },
],
output_config: {
format: zodOutputFormat(TicketTriage),
},
});
return parseTriageMessage(message);
}
Note that zodOutputFormat() works with plain create() too — it just performs no automatic parsing there, which is exactly what we want.5 The guards run in a deliberate order: stop-reason checks first (cheap, decisive), then JSON syntax, then schema semantics. Each failure mode gets its own typed reason, so callers can retry truncations with a higher max_tokens while routing refusals to a human queue instead of a retry loop.
Wire up a runnable entry point, src/index.ts:
import { triageTicketSafe } from "./extract-manual.ts";
const sampleTicket = `
From: dana@example.com
Subject: Charged twice and the dashboard is down
Hi, order ORD-98214 was charged twice this morning, and now your status
dashboard will not load at all for our whole team. We need the duplicate
charge refunded before Friday. This is blocking our finance close.
`;
const result = await triageTicketSafe(sampleTicket);
if (result.ok) {
console.log("Triage:", JSON.stringify(result.data, null, 2));
} else {
console.error(`Triage failed (${result.reason}): ${result.detail}`);
process.exitCode = 1;
}
Step 6 — Strict tool use: validated inputs for your functions
JSON outputs control what Claude says; strict tool use controls how Claude calls your functions — and the two can run in the same request.1 Without strict mode, Claude might pass passengers: "two" or passengers: "2" where your function expects a number; with strict: true on the tool definition, tool inputs are grammar-constrained to the schema and the tool name is always valid — one of your provided tools or server tools.6 Create src/strict-tool.ts:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const lookupOrderTool: Anthropic.Tool = {
name: "lookup_order",
description: "Look up a customer's order by order id",
strict: true,
input_schema: {
type: "object",
properties: {
order_id: {
type: "string",
description: "The order id, for example ORD-12345",
},
include_refunds: {
type: "boolean",
description: "Whether to include refund history",
},
},
required: ["order_id", "include_refunds"],
additionalProperties: false,
},
};
export async function askAboutOrder(question: string): Promise<void> {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: [{ role: "user", content: question }],
tools: [lookupOrderTool],
});
for (const block of response.content) {
if (block.type === "tool_use" && block.name === "lookup_order") {
// With strict: true, this input is guaranteed to match input_schema:
// order_id is a string, include_refunds is a boolean, no extra keys.
const input = block.input as {
order_id: string;
include_refunds: boolean;
};
console.log(
`Claude wants order ${input.order_id} (refunds: ${input.include_refunds})`,
);
}
}
}
strict sits at the top level of the tool definition, alongside name, description, and input_schema, and the schema obeys the same JSON Schema subset from Step 2.6 Strict tools share the same request-wide complexity budget: at most 20 strict tools per request, 24 optional parameters across all strict tool and JSON output schemas, and 16 union-typed parameters across all strict schemas.1 When you combine strict tools with JSON outputs in one request, you get validated tool calls during the agent loop and a schema-guaranteed final answer — and note that the JSON-outputs grammar applies only to Claude's direct output — not to tool-use calls, tool results, or thinking blocks — with grammar state resetting between sections.1
For a full agentic loop around tool calls (multi-turn tool_use/tool_result), see our Claude tool use agentic loop tutorial; strict mode drops into that loop unchanged.
Step 7 — Test the guards offline, free
The failure paths are exactly the code you cannot afford to discover broken in production, and they are testable without an API key: parseTriageMessage() takes a Message, so feed it synthetic ones. Create src/guards.test.ts:
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Message } from "@anthropic-ai/sdk/resources/messages";
import { parseTriageMessage } from "./extract-manual.ts";
function fakeMessage(
text: string,
stopReason: Message["stop_reason"],
): Message {
return {
id: "msg_test_0001",
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
content: [{ type: "text", text, citations: null }],
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
cache_creation: null,
server_tool_use: null,
service_tier: null,
},
} as Message;
}
const validJson = JSON.stringify({
category: "billing",
priority: "urgent",
summary: "Customer was double-charged and the dashboard is down.",
customerEmail: "dana@example.com",
mentionsOutage: true,
followUpActions: ["Refund duplicate charge", "Escalate dashboard outage"],
orderId: "ORD-98214",
});
test("valid JSON with end_turn parses into typed data", () => {
const result = parseTriageMessage(fakeMessage(validJson, "end_turn"));
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.data.category, "billing");
assert.equal(result.data.orderId, "ORD-98214");
}
});
test("refusal stop_reason is rejected before parsing", () => {
const result = parseTriageMessage(
fakeMessage("I can't help with that request.", "refusal"),
);
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "refusal");
});
test("max_tokens stop_reason is flagged as truncated", () => {
const truncated = validJson.slice(0, 60);
const result = parseTriageMessage(fakeMessage(truncated, "max_tokens"));
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "truncated");
});
test("schema-violating JSON is caught by Zod", () => {
const wrong = JSON.stringify({ category: "spam", priority: "urgent" });
const result = parseTriageMessage(fakeMessage(wrong, "end_turn"));
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.reason, "schema_mismatch");
});
Verification
Run the three checks in order:
npm run typecheck
npm test
npm run triage
Expected: typecheck exits silently with code 0. The test run reports:
# tests 4
# pass 4
# fail 0
And npm run triage (this one calls the API and needs ANTHROPIC_API_KEY) prints a triage object shaped like:
{
"category": "billing",
"priority": "urgent",
"summary": "Customer was double-charged and the status dashboard is down for their team.",
"customerEmail": "dana@example.com",
"mentionsOutage": true,
"followUpActions": [
"Refund the duplicate charge on ORD-98214",
"Escalate the dashboard outage"
],
"orderId": "ORD-98214"
}
Field values will vary run to run — the shape will not. Note the required-first ordering from Step 4: orderId is last because it is the only optional field. The typecheck and all four offline tests were run end-to-end while writing this guide; the live response shape follows the schema guarantee documented for JSON outputs.1
What does structured outputs cost?
There is no separate fee for the feature on the Claude API; you pay standard token rates — $3/$15 per million input/output tokens on Sonnet 4.6, $1/$5 on Haiku 4.5 — plus a few real cost mechanics worth knowing.2
First, the API injects an additional system prompt explaining the expected output format, so input token counts run slightly higher, and changing output_config.format invalidates the prompt cache for that conversation thread.1 Second, the first request with a new schema pays a grammar-compilation latency penalty; compiled grammars are cached for 24 hours from last use, and the cache is invalidated by schema structure changes or by changing the set of tools in the request — editing only name or description fields does not invalidate it.1 Third, structured outputs work with the Batch API at its standard 50% discount ($1.50/$7.50 per MTok on Sonnet 4.6), which makes batch the obvious lane for bulk extraction jobs.12
Two incompatibilities to plan around: citations cannot be combined with JSON outputs (the API returns a 400 error), and message prefilling — the old trick of starting the assistant turn with { — is incompatible with JSON outputs.1
JSON mode vs structured outputs: migrating older patterns
What is the difference between JSON mode and structured outputs in Claude? "JSON mode" was never a switch on the Claude API — it was a family of prompting workarounds: instructing Claude to emit JSON, prefilling { as the assistant turn, or defining a dummy tool and forcing Claude to "call" it just to harvest schema-shaped input. Those patterns mostly produce valid JSON; structured outputs guarantee it via constrained decoding.1
Migration map:
| Old pattern | Replace with | Why |
|---|---|---|
| "Respond only with valid JSON..." prompt | output_config.format | Guarantee instead of a request; no schema drift1 |
Prefilling { as assistant turn | output_config.format | Prefilling is incompatible with JSON outputs anyway1 |
Dummy tool + forced tool_choice for extraction | JSON outputs | The response is the JSON; no tool-roundtrip ceremony1 |
| Real tools without validation | strict: true per tool | Inputs grammar-constrained to your schema6 |
Beta output_format parameter + beta header | output_config.format, no header | The beta header structured-outputs-2025-11-13 is no longer required; the old parameter and header keep working for a transition period1 |
That last row matters if you adopted the feature during its 2025 beta: new code should use output_config.format, and many third-party snippets you will find online still show the older shape.
Troubleshooting
400: "Schema is too complex for compilation". Your schemas passed the explicit limits but the compiled grammar exceeds internal size limits (compilation also has a 180-second timeout). Apply the documented fixes in order: mark only critical tools strict, convert optional parameters to required (each optional roughly doubles part of the grammar state space), flatten nesting, or split tools across requests.1
400 on an unsupported schema feature. Raw schemas (no SDK transform) reject minLength, minimum, recursive schemas, external $ref, additionalProperties other than false, and similar — the error body names the offending feature.1 Either drop the constraint, or let zodOutputFormat()/jsonSchemaOutputFormat() transform-and-validate-locally for you.5
AnthropicError: Failed to parse structured output from parse(). This is the SDK's local parsing or validation failing — most often a refusal or a max_tokens truncation rather than a server bug. The thrown error carries the JSON-syntax detail (refusal text is not JSON) or up to five Zod issues (schema mismatch), but never the Message. If you need the stop reason and usage on failures, use the create() + guard pattern from Step 5.5
Pattern or enum violations got through to Zod. Expected with the Zod helper in SDK 0.102.0: enum and pattern ride along as description text rather than grammar constraints, and local validation is the enforcement point (Step 4). Send a raw schema if grammar-level enforcement of those keywords matters for your use case.1
Output property order looks shuffled. It is deterministic, just not what people expect: required properties first in schema order, then optional properties in schema order. Mark everything required if order matters downstream, or sort after parsing.1
Next steps
Wrap the triage service in an agent loop with our Claude tool use in TypeScript tutorial, regression-test your extraction prompts in CI with promptfoo, or put the whole thing behind a self-hosted gateway with LiteLLM Proxy. For bulk workloads, the Batch API combination from the cost section halves the rate — start with the schema you built here and submit your backlog of unprocessed tickets as a batch.
Footnotes
-
Anthropic, "Structured outputs" — Claude API documentation, https://platform.claude.com/docs/en/build-with-claude/structured-outputs (fetched June 7, 2026). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 ↩29
-
Anthropic, "Pricing" — Claude API documentation, https://platform.claude.com/docs/en/about-claude/pricing (fetched June 7, 2026). ↩ ↩2 ↩3 ↩4
-
Node.js release schedule, https://endoflife.date/nodejs (Node 24 Active LTS; maintenance through April 2028; checked June 7, 2026). ↩
-
npm registry: @anthropic-ai/sdk 0.102.0 (peer range
zod ^3.25.0 || ^4.0.0), zod 4.4.3, typescript 6.0.3, tsx 4.22.4, @types/node 24.13.1 (checked June 7, 2026). ↩ ↩2 -
Anthropic TypeScript SDK helpers documentation (helpers.md, anthropic-sdk-typescript, main branch, fetched June 7, 2026) and shipped source of
@anthropic-ai/sdk0.102.0 (lib/parser.js,helpers/zod.js,lib/transform-json-schema.js). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Anthropic, "Strict tool use" — Claude API documentation, https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use (fetched June 7, 2026). ↩ ↩2 ↩3