Structured Outputs
Structured outputs turn free-form model text into typed objects your app can validate, store, and render — without fragile regex on chat responses.
Last reviewed: June 2026
Provider APIs for JSON mode and schema enforcement change frequently. Pin SDK versions and verify OpenAI structured outputs and Anthropic docs before production.
The problem
Raw completion:
The sentiment is positive with high confidence.
Your parser breaks when the model adds a preamble, uses "Positive" instead of "positive", or omits a field. Structured outputs constrain the response to a schema you define.
Use structured outputs when:
- Extracting form fields, entities, or classifications
- Routing user intent to handlers
- Generating config or API payloads consumed by code
- Building eval harnesses with machine-readable scores
Skip structured outputs when:
- User-facing prose is the product (chat, docs)
- Schema changes every request (prefer tool calling with dynamic tools)
Approach comparison
| Approach | Provider | Guarantee level |
|---|---|---|
Zod + generateObject (AI SDK) | OpenAI, Anthropic, others | Schema-validated object; SDK retries on parse failure |
OpenAI response_format: json_schema | OpenAI | Constrained decoding to JSON Schema |
| Anthropic tool use with single tool | Anthropic | Tool input validated against schema |
| Prompt "respond with JSON only" | Any | Unreliable — use only for prototypes |
Prefer schema-enforced paths for production. See OpenAI API and Anthropic API.
Vercel AI SDK + Zod (recommended)
Works across providers with one pattern:
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
const TicketSchema = z.object({
category: z.enum(["billing", "technical", "other"]),
priority: z.enum(["low", "medium", "high"]),
summary: z.string().max(200),
suggestedActions: z.array(z.string()).max(5),
});
export async function POST(req: Request) {
const { message } = await req.json();
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: TicketSchema,
prompt: `Classify this support message:\n\n${message}`,
});
return Response.json(object);
}
Install: npm install ai @ai-sdk/openai zod
OpenAI native JSON Schema
Direct API when not using the AI SDK:
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: userText }],
response_format: {
type: "json_schema",
json_schema: {
name: "ticket_classification",
strict: true,
schema: {
type: "object",
properties: {
category: { type: "string", enum: ["billing", "technical", "other"] },
priority: { type: "string", enum: ["low", "medium", "high"] },
summary: { type: "string" },
},
required: ["category", "priority", "summary"],
additionalProperties: false,
},
},
},
});
const parsed = TicketSchema.parse(JSON.parse(response.choices[0].message.content!));
Always validate with Zod after parse — defense in depth.
Anthropic pattern (tool as schema)
Define one tool whose input_schema is your output shape:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
tools: [{
name: "classify_ticket",
description: "Return structured ticket classification",
input_schema: {
type: "object",
properties: {
category: { type: "string", enum: ["billing", "technical", "other"] },
priority: { type: "string", enum: ["low", "medium", "high"] },
summary: { type: "string" },
},
required: ["category", "priority", "summary"],
},
}],
tool_choice: { type: "tool", name: "classify_ticket" },
messages: [{ role: "user", content: userText }],
});
const toolUse = response.content.find((b) => b.type === "tool_use");
const object = TicketSchema.parse(toolUse?.input);
Model IDs change — verify current IDs in Anthropic docs.
Schema design tips
| Tip | Example |
|---|---|
| Use enums for closed sets | z.enum(["open", "closed"]) not free string |
| Cap array length | .max(5) on suggestedActions |
| Cap string length | .max(200) on summaries |
| Optional vs required | Only mark optional what you truly tolerate missing |
Describe fields in .describe() | Helps model fill ambiguous fields |
Production concerns
| Concern | What to do |
|---|---|
| Cost | Use smaller models (gpt-4o-mini, Haiku) for classification |
| Latency | Structured output adds minimal overhead vs free text |
| Failure modes | Wrap in try/catch; Zod parse errors → fallback or retry once |
| Drift | Version schemas; log raw responses in staging |
| Security | Never eval() parsed JSON; treat as untrusted input |
Streaming structured output
For UX that shows partial JSON, use AI SDK streamObject:
import { streamObject } from "ai";
const result = streamObject({
model: openai("gpt-4o-mini"),
schema: TicketSchema,
prompt: userText,
});
return result.toTextStreamResponse();
Client receives incremental partial object updates.