Structured Outputs and JSON Schema in LLMs (2026)
# Structured outputs guarantee LLMs return valid JSON matching your schema — no parsing, no retry loops. Learn how to use native structured outputs in Claude, OpenAI, and Gemini with Zod schema validation.
Structured outputs solve the most common LLM integration headache: the model returns almost-valid JSON that breaks your parser. Native structured output support — available across Claude, OpenAI, and Gemini — guarantees schema compliance at the model level. Here's how to use it properly.
Why Structured Outputs Matter
Without structured outputs, you're parsing LLM text and hoping:
// Fragile: model might add markdown, preamble, or trailing text
const response = await anthropic.messages.create({...});
const text = response.content[0].text;
try {
const data = JSON.parse(text); // fails if model says "Here's the JSON: {...}"
} catch {
// retry, strip markdown, pray
}
With structured outputs, the model's sampling is constrained to produce valid JSON matching your schema:
// Reliable: schema-constrained generation
const { object } = await generateObject({
model: anthropic("claude-sonnet-4-6"),
schema: MySchema,
prompt: "...",
}); // object is typed and valid — no parsing needed
Defining Schemas with Zod
Zod is the standard schema library for TypeScript. The AI SDK converts Zod schemas to JSON Schema automatically:
import { z } from "zod";
import { generateObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const ProductSchema = z.object({
name: z.string().describe("Product name"),
category: z.enum(["software", "hardware", "service"]),
price: z.number().positive().describe("Price in USD"),
features: z.array(z.string()).min(1).max(10),
available: z.boolean(),
releaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
});
type Product = z.infer<typeof ProductSchema>;
async function extractProduct(description: string): Promise<Product> {
const { object } = await generateObject({
model: anthropic("claude-sonnet-4-6"),
schema: ProductSchema,
prompt: `Extract product information from: "${description}"`,
});
return object; // fully typed as Product
}
Zod descriptions (.describe()) are included in the JSON schema and help the model understand what each field means.
Nested Objects and Arrays
const ArticleAnalysisSchema = z.object({
title: z.string(),
sentiment: z.enum(["positive", "neutral", "negative"]),
topics: z.array(z.string()),
entities: z.array(z.object({
name: z.string(),
type: z.enum(["person", "organization", "location", "product"]),
mentions: z.number().int().positive(),
})),
readingLevel: z.enum(["elementary", "intermediate", "advanced", "expert"]),
wordCount: z.number().int(),
keyInsights: z.array(z.object({
insight: z.string(),
evidence: z.string(),
confidence: z.number().min(0).max(1),
})).max(5),
});
const analysis = await generateObject({
model: anthropic("claude-sonnet-4-6"),
schema: ArticleAnalysisSchema,
prompt: `Analyze this article:\n${articleText}`,
});
// TypeScript knows:
// analysis.entities[0].type is "person" | "organization" | "location" | "product"
// analysis.keyInsights[0].confidence is a number between 0 and 1
Direct Anthropic API Structured Output
Without the AI SDK, use Anthropic's native tool-use pattern for structured output:
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
const client = new Anthropic();
async function structuredExtract<T>(
prompt: string,
schema: z.ZodSchema<T>,
schemaName: string,
schemaDescription: string
): Promise<T> {
const jsonSchema = zodToJsonSchema(schema); // use zod-to-json-schema package
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: [{
name: schemaName,
description: schemaDescription,
input_schema: jsonSchema as Anthropic.Tool["input_schema"],
}],
tool_choice: { type: "tool", name: schemaName }, // force tool use
messages: [{ role: "user", content: prompt }],
});
const toolUse = response.content.find((b) => b.type === "tool_use");
if (!toolUse || toolUse.type !== "tool_use") {
throw new Error("Model did not call the tool");
}
return schema.parse(toolUse.input);
}
// Usage
const extracted = await structuredExtract(
`Extract from: "${emailText}"`,
z.object({ sender: z.string(), subject: z.string(), priority: z.enum(["high", "normal", "low"]) }),
"extract_email_metadata",
"Extract structured metadata from an email"
);
Setting tool_choice: { type: "tool", name: "..." } forces the model to call the tool — guaranteeing structured output.
Streaming Structured Objects
Stream partial objects as they're generated:
import { streamObject } from "ai";
async function* streamAnalysis(text: string) {
const { partialObjectStream } = streamObject({
model: anthropic("claude-sonnet-4-6"),
schema: ArticleAnalysisSchema,
prompt: `Analyze: ${text}`,
});
for await (const partial of partialObjectStream) {
yield partial;
// partial is typed as DeepPartial<ArticleAnalysis>
// fields populate as they're generated
}
}
// Usage: update UI progressively
for await (const partial of streamAnalysis(article)) {
if (partial.title) setTitle(partial.title);
if (partial.sentiment) setSentiment(partial.sentiment);
if (partial.topics) setTopics(partial.topics);
}
Users see results appear as they're generated rather than waiting for the full response.
Handling Schema Violations
Even with constrained generation, validate defensively:
async function safeGenerateObject<T>(
schema: z.ZodSchema<T>,
prompt: string,
retries = 2
): Promise<T> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const { object } = await generateObject({
model: anthropic("claude-sonnet-4-6"),
schema,
prompt: attempt > 0 ? `${prompt}\nIMPORTANT: Return valid JSON matching the schema exactly.` : prompt,
});
return schema.parse(object); // re-validate even though AI SDK already did it
} catch (err) {
if (attempt === retries) throw err;
console.warn(`Structured generation attempt ${attempt + 1} failed:`, err);
}
}
throw new Error("unreachable");
}
Enum Handling and Optional Fields
const ClassificationSchema = z.object({
category: z.enum(["bug", "feature", "question", "documentation"]),
priority: z.enum(["critical", "high", "medium", "low"]).default("medium"),
assignee: z.string().optional(), // model may not always include
tags: z.array(z.string()).default([]),
estimatedHours: z.number().positive().optional(),
});
// With optional fields, always check before use
const result = await generateObject({/* ... */});
if (result.object.assignee) {
assignTicket(result.object.assignee);
}
// TypeScript enforces this — result.object.assignee is string | undefined
Performance: generateObject vs Manual Parsing
| Approach | Reliability | Dev time | Tokens used |
|---------|------------|---------|------------|
| Manual JSON parsing | ~85% | High | Baseline |
| JSON mode (OpenAI) | ~99% | Medium | Baseline + schema |
| generateObject (AI SDK) | 99.9%+ | Low | Baseline + schema |
| Tool-force (Anthropic) | 99.9%+ | Medium | Baseline + tool def |
The schema overhead (50–200 tokens) is well worth eliminating retry logic and error handling.
FAQ
Does structured output work with all models?
Native structured output works with Claude 3+, GPT-4o, and Gemini 1.5+. Older or smaller models may produce invalid JSON even with constraints. Use claude-sonnet-4-6 or later for reliable results.
Can I use discriminated unions in the schema?
Zod discriminated unions (z.discriminatedUnion) work with generateObject. The AI SDK converts them to oneOf in JSON Schema. Test carefully — complex union schemas can confuse the model.
What about z.any() or z.unknown()?
Avoid them in structured output schemas — they defeat the purpose. If you truly need flexible structure, use z.record(z.string(), z.unknown()) and validate the specific keys you care about.
How do I handle very large schemas?
Break large schemas into multiple focused calls. A schema with 30 fields is harder for the model than 3 schemas with 10 fields each. Use z.pick() to create sub-schemas for different extraction passes.
Is generateObject slower than generateText?
No — the generation speed is identical. generateObject just adds schema validation after generation.