Two approaches to the same problem: making sure an LLM returns valid, typed JSON. One is baked into the API. The other is a validation library you apply after the fact. Here's when to use which.
The problem
You ask an LLM to return JSON. Sometimes you get valid JSON. Sometimes you get JSON wrapped in markdown. Sometimes you get a friendly explanation instead of JSON. Sometimes you get JSON with wrong field names or missing fields.
// What you want
{ "sentiment": "positive", "confidence": 0.92, "topics": ["AI", "automation"] }
// What you sometimes get
"Sure! Here's the analysis:
json
{ sentiment: 'positive' }
Both Structured Outputs and Zod solve this. They solve it differently.
OpenAI Structured Outputs
Available in the OpenAI API with response_format: { type: "json_schema", json_schema: { ... } }. The model is constrained during generation to only produce valid JSON matching your schema.
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Analyze the sentiment of: 'This product is amazing'" }],
response_format: {
type: "json_schema",
json_schema: {
name: "sentiment_analysis",
schema: {
type: "object",
properties: {
sentiment: { type: "string", enum: ["positive", "negative", "neutral"] },
confidence: { type: "number", minimum: 0, maximum: 1 },
topics: { type: "array", items: { type: "string" } },
},
required: ["sentiment", "confidence", "topics"],
additionalProperties: false,
},
},
},
});
const result = JSON.parse(response.choices[0].message.content);
// Guaranteed to match the schema — the model couldn't generate anything else
Pros:
- 100% structural guarantee — invalid JSON is impossible
- No post-processing or validation needed
- Works at the token generation level (constrained decoding)
- Supports nested objects, arrays, enums, unions
Cons:
- OpenAI only (not available with Claude, Gemini, Mistral via their native APIs)
- Slightly slower (constraint checking during generation)
- Schema must be JSON Schema format (not Zod)
- Can't express all constraints (no regex patterns, no custom validation logic)
Zod validation (post-generation)
Use Zod (or any validation library) to parse and validate the LLM's output after generation. Works with any model from any provider.
import { z } from 'zod';
const SentimentSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
topics: z.array(z.string()).min(1),
});
type Sentiment = z.infer<typeof SentimentSchema>;
async function analyzeSentiment(text: string): Promise<Sentiment> {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 500,
messages: [{ role: "user", content: `Analyze sentiment. Return JSON only: ${text}` }],
});
const raw = response.content[0].text;
// Extract JSON if wrapped in markdown
const jsonMatch = raw.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("No JSON found in response");
const parsed = JSON.parse(jsonMatch[0]);
return SentimentSchema.parse(parsed); // Throws if invalid
}
Pros:
- Works with any LLM (Claude, GPT, Gemini, Mistral, local models)
- Full TypeScript type inference (
z.infer<typeof Schema>) - Custom validation logic (regex, transforms, refinements)
- Composable — schemas combine, extend, merge
- Battle-tested library with huge ecosystem
Cons:
- Validation happens after generation — the model might produce invalid output
- Need retry logic for when validation fails
- Must handle JSON extraction from freeform text
- Extra code for the validation layer
The Vercel AI SDK solution: best of both worlds
The AI SDK's generateObject combines Zod schemas with provider-specific structured output when available:
import { generateObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
const { object } = await generateObject({
model: anthropic('claude-sonnet-4-6'),
schema: z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
topics: z.array(z.string()),
}),
prompt: 'Analyze: "This product is amazing"',
});
// object is fully typed as { sentiment: "positive" | "negative" | "neutral", confidence: number, topics: string[] }
The SDK automatically uses structured outputs when the provider supports it (OpenAI), and falls back to prompt-based JSON generation + Zod validation for others (Claude, Gemini). One API, best strategy per provider.
Decision framework
Use OpenAI Structured Outputs when:
- You're exclusively on OpenAI models
- Schema compliance is critical (medical, financial, legal data)
- You can't afford retry loops on invalid output
- Your schema fits JSON Schema constraints
Use Zod validation when:
- You use multiple LLM providers
- You need custom validation logic beyond structural types
- You want TypeScript type inference from your schemas
- You're already using Zod elsewhere in your stack
Use the AI SDK's generateObject when:
- You want the best of both approaches automatically
- You might switch providers in the future
- You're building a Next.js app (natural fit)
The practical recommendation
For most TypeScript projects in 2026: use the AI SDK with Zod schemas. You get type safety, provider flexibility, and automatic structured output support where available. It's one line of code and it handles the complexity for you.
The AI SaaS Starter Kit uses this exact pattern — Zod schemas for all AI-generated structured data, AI SDK for the provider abstraction, and automatic retry on validation failures.
Top comments (0)