[SL_CORE]
< CD ..||6 min read

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][typescript][llm][json-schema]

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.