
Structured Outputs with the Vercel AI SDK: generateObject + Zod + Claude (TypeScript)
Define a Zod schema and call generateObject({ model: anthropic('claude-sonnet-5'), schema, prompt }) from the Vercel AI SDK — you get back a fully typed object that matches the schema, with automatic retry/repair on invalid output, so there's no brittle JSON parsing. Use streamObject to stream partial objects into progressive UI.
How do you get guaranteed structured output from Claude in TypeScript?
Define a Zod schema for the shape you want, then pass it to generateText from the Vercel AI SDK as output: Output.object({ schema }), with model: anthropic('claude-sonnet-5'). You get back a fully typed output value that the SDK has validated against the schema, so there is no brittle JSON.parse() and no regex cleanup. When you want that data to appear progressively in the UI, call streamText with the same output option and read partialOutputStream as it fills in. That is the whole idea; the rest of this post is the production detail.
The problem: parsing free-text LLM output is fragile
Ask a model to “return JSON” and it will, most of the time. The failure modes are the problem. It wraps the JSON in a Markdown fence. It adds a chatty preamble like “Sure, here’s the data:”. It emits a trailing comma, drops a required field, or returns "160.00" as a string where your code expected a number. Each of those is a JSON.parse() throw or a silent wrong-type bug, and you end up writing a try/catch with a regex repair pass and a retry loop around every call.
That code is where production incidents live. You get paged because a receipt came back with total as null, or because the model helpfully translated an enum value into Spanish and your switch statement fell through. The core issue is that free text is an unbounded output space, and you are trying to force a bounded shape onto it after the fact.
Prompt & parse (fragile)
- Prompt: return JSON and hope
- JSON.parse() inside try/catch
- Regex-strip fences and preambles
- Retry loop on parse failure
- Still breaks on null / wrong-type fields
- No compile-time types
Output.object + Zod
- Schema declares the exact shape
- SDK constrains generation to it
- Validates the result against the schema
- Typed output inferred from the schema
- No manual parsing or cleanup
- One code path, no repair layer
The fix: schema-constrained generation (define the shape, get it back)
Schema-constrained generation flips the order. Instead of parsing after the fact, you declare the shape up front and the SDK makes the model produce output that fits it. You describe the object once with Zod, hand that schema to Output.object, and the return value is already the typed data — no intermediate string to babysit.
Two things come for free. First, TypeScript infers the result type directly from the Zod schema, so output.total is a number at compile time and your editor autocompletes every field. Second, the SDK validates the model’s output against the schema before it hands it to you: if the model returns something the schema rejects, you get a typed error to catch at the call site, not a silently-broken object three functions downstream. You delete the entire parse-and-pray layer because the SDK owns the boundary.
This is the TypeScript counterpart to Anthropic’s own structured outputs mode, which constrains generation at the token level on the raw Messages API. Same guarantee — the shape is enforced, not hoped for — with the ergonomics of a schema library you already use in your Next.js app.
Setup: install ai + @ai-sdk/anthropic + zod (AI SDK 7 is ESM-only)
You need three packages: the core ai package, the Claude provider adapter @ai-sdk/anthropic, and zod for the schema.
npm install ai @ai-sdk/anthropic zod
The current major version is AI SDK 7 (released June 25, 2026), and it is ESM-only. CommonJS require() is not supported — you must use import syntax and set "type": "module" in your package.json (or use a .mjs extension). If you are on an older CommonJS setup, this is the one migration step that will bite you before any code runs.
{
"type": "module",
"dependencies": {
"ai": "^7.0.0",
"@ai-sdk/anthropic": "latest",
"zod": "latest"
}
}
The provider reads ANTHROPIC_API_KEY from the environment, so put it in your .env and you are done — no client construction needed for the default case. If you need a custom baseURL, headers, or a custom fetch, import createAnthropic instead of anthropic and build a configured provider. For the raw Messages API underneath all this, see how to use the Claude API.
Output.object + Zod: extract structured data into typed output
Here is the real example: pull structured fields out of a messy free-text receipt into typed output. In AI SDK 7, structured generation is part of the generateText flow — you pass an output specification and read result.output.
import { generateText, Output } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
const receiptSchema = z.object({
merchant: z.string(),
date: z.string().describe('ISO 8601 date, e.g. 2026-07-10'),
subtotal: z.number(),
tax: z.number().describe('IVA amount in MXN'),
total: z.number(),
lineItems: z.array(
z.object({
description: z.string(),
quantity: z.number(),
unitPrice: z.number(),
}),
),
});
const { output } = await generateText({
model: anthropic('claude-sonnet-5'),
output: Output.object({ schema: receiptSchema }),
prompt: `Extract the receipt data from this text:\n\n${rawText}`,
});
// output is fully typed: output.total is a number, output.lineItems is an array
console.log(output.merchant, output.total);
output is not a string you still have to parse — it is the parsed, validated, typed result. The .describe() calls are not decoration; they are passed to the model as field-level hints, which meaningfully improves extraction quality on ambiguous fields like dates and tax amounts. Keep the schema exact — only declare the fields you actually want, and the model will not invent extras.
Pick the model: anthropic(‘claude-sonnet-5’) vs claude-opus-4-8
You select the model by passing a current Claude id string to anthropic(). For structured extraction, anthropic('claude-sonnet-5') is the balanced default — fast and cheap enough for high volume, accurate enough for typical receipt and form extraction. Reach for anthropic('claude-opus-4-8') when the task needs the most capability: dense multi-page documents, subtle classification, or reasoning-heavy extraction where Sonnet starts to miss.
// Balanced default for most extraction jobs
model: anthropic('claude-sonnet-5'),
// Most capable — dense documents, harder reasoning
model: anthropic('claude-opus-4-8'),
One caveat worth stating plainly: model ids move. The AI SDK docs sometimes show stale example ids like claude-sonnet-4-20250514 — do not copy those. Verify the current id against the Claude models documentation before you ship, because a wrong id is a runtime error, not a compile-time one.
What happens when the model returns invalid output
This is the part that earns schema-constrained generation its place in production. The guarantee is validation, not blind trust: the SDK constrains the model toward your Zod schema and then checks the result against it. When the output cannot be coerced into the schema — a missing required field, a wrong type, malformed JSON — the SDK does not hand you a half-broken object. It raises a typed NoObjectGeneratedError that carries the offending text and metadata, so you catch one clear failure at the call site.
Concretely, that means the parse-and-pray pattern disappears. You do not write a try/catch around JSON.parse, you do not strip Markdown fences, and you do not scatter ad-hoc string bugs through your code. You get one place to handle failure — the call site — and everywhere else you work with a typed value the SDK already validated.
The important boundary, same as with Anthropic’s native mode: the SDK guarantees the shape, not the truth of the values. A schema-valid receipt can still carry a wrong total or a tax figure that does not reconcile. Validate the values yourself after you get the output — check that subtotal + tax === total, verify an RFC checksum, confirm the date is plausible. Trust the shape from the SDK; verify the numbers with your own code.
streamText + Output.object: partial output for progressive UI
When the object is large or the user is watching, waiting for the whole thing to generate feels slow. streamText with the same output specification fixes that by exposing partialOutputStream — deeply-partial versions of your schema as fields fill in, so your UI can render progressively instead of blocking on the final result.
import { streamText, Output } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const { partialOutputStream } = streamText({
model: anthropic('claude-sonnet-5'),
output: Output.object({ schema: receiptSchema }),
prompt: `Extract the receipt data from this text:\n\n${rawText}`,
});
for await (const partial of partialOutputStream) {
// partial is the output filled in so far — render it as it grows
render(partial);
}
Each value from partialOutputStream is the output so far — fields appear as the model emits them. In a form-autofill flow, that means the merchant name populates, then the total, then the line items stream in one by one, and the user sees progress instead of a spinner. One honest caveat: partial values streamed this way are not validated against your schema while they arrive — validation applies to the complete result, not to each in-flight partial. So render the partials for feedback, and only trust the final resolved output for anything load-bearing.
Enums, nested schemas, and arrays (classification + extraction)
Two jobs cover most of what you will build: classification (pick one of N labels) and extraction (pull nested structured data). Zod expresses both cleanly inside Output.object, and the same schema-guarantee applies.
import { generateText, Output } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
const routingSchema = z.object({
// Classification: a closed set — the model cannot return anything else
intent: z.enum(['billing', 'technical', 'sales', 'refund']),
priority: z.enum(['low', 'medium', 'high']),
// Nested extraction
customer: z.object({
name: z.string(),
email: z.string().email(),
}),
// Array of structured items
tags: z.array(z.string()),
});
const { output } = await generateText({
model: anthropic('claude-sonnet-5'),
output: Output.object({ schema: routingSchema }),
prompt: `Classify and extract from this support message:\n\n${message}`,
});
// output.intent is typed as 'billing' | 'technical' | 'sales' | 'refund'
switch (output.intent) {
case 'refund':
return routeToRefunds(output);
// ...
}
The z.enum is the workhorse for classification: the returned value is guaranteed to be one of your labels, and TypeScript narrows output.intent to that union, so your switch is exhaustive and safe. Nested objects and arrays compose the same way — declare the tree, get the tree back typed.
How this maps to Anthropic’s own structured-output mode
If you have read the Claude structured outputs post, this will feel familiar — it should. Anthropic’s native mode passes a JSON Schema via output_config.format and constrains token generation at the sampling layer so the model literally cannot emit a token that breaks the schema. The Vercel AI SDK’s Output.object gives you the same guaranteed-shape outcome, but expressed in Zod, validated by the SDK, and typed by TypeScript inference.
Which do you reach for? Use the AI SDK when you are already in a TypeScript app — Next.js, a React front end with partialOutputStream powering progressive UI, or a codebase where Zod is your validation layer everywhere. Use the raw Messages API mode when you want the token-level guarantee with no framework in between, or when you are calling from a language where the SDK is not a fit. They are the same idea at two altitudes; pick by ergonomics, not by capability. If your app also needs a support agent to decide what to do with the extracted data, the what is an AI agent primer sits one level up from this.
Pitfalls: over-constraining, the ESM requirement, and verifying names at draft
Three things trip people up. Over-constraining the schema. If you make every field required and forbid any flexibility, the model has to invent values it does not have, and you get hallucinated data that is technically schema-valid. Use .optional() for fields that genuinely may be absent, and let the model return them empty rather than fabricating.
The ESM requirement. AI SDK 7 is ESM-only. If your build fails with a require() of ES Module error, that is this — set "type": "module" and convert your require calls to import. There is no CommonJS fallback.
Verify the exact names at draft time. APIs moved across major versions of the SDK — this post itself is written for AI SDK 7, where structured output lives on the output option of generateText and streamText rather than the older standalone functions. Pin AI SDK 7 and check the current spelling of things — the Output helpers, the streaming property, the useChat import path — against ai-sdk.dev rather than pattern-matching an older tutorial. A v3 or v4 example will look right and fail at runtime. This is a fast-moving surface; the AI SDK 7 changelog is the source of truth.
Where you’d use it: CFDI/receipt extraction, form autofill, request routing
Three concrete places this pays off. CFDI and receipt extraction — turn a photographed receipt or a pasted invoice into typed output with merchant, subtotal, tax, and lineItems, then validate the totals before you build the CFDI. This is the exact workflow that used to be a wall of JSON.parse and repair code. Form autofill — streamText with Output.object populates a form field by field as the model reads a document, so the user watches it fill in. Request routing — a z.enum classifies an incoming support message or webhook and hands you a narrowed union type to switch on, replacing a pile of string comparisons.
- Define the Zod schemaExact fields, .describe() hints, .optional() where a value may be missing
- Call generateText / streamText with Output.objectmodel: anthropic('claude-sonnet-5') — verify the id
- Trust the shapeTyped output back, no parsing; a typed error if it cannot fit
- Validate the valuesTotals reconcile, RFC checksum, plausible dates
- ActBuild CFDI, autofill the form, route the request
Keep going: streaming & tool calling, Claude structured outputs, RAG
Structured output is one of four things the Vercel AI SDK does well. For the token-streaming chat and function-calling side — streamText, useChat, and the multi-step tool loop — read the sibling post on the Vercel AI SDK with Claude: streaming and tool calling. To go a level lower to the raw guarantee, see Claude structured outputs in production and the fundamentals in how to use the Claude API. And when your extraction needs to pull from your own documents rather than a single prompt, combine this with retrieval — see build a RAG system with Claude. All of it lives under the AI agents hub.