Challenge 1.5: Structured output
How do you get an LLM to return JSON instead of free text — reliably, every time? And how do you ensure the JSON has exactly the fields and types you need?
OVERVIEW
Section titled “OVERVIEW”Top: With a Zod schema and Output.object you get a typed object. Bottom: Without a schema you get free text that you’d have to parse manually.
Without structured output: You ask the LLM for JSON, sometimes get valid JSON, sometimes free text wrapped in a Markdown code block. JSON.parse breaks, you build regex hacks, the extraction is fragile. Any change to the prompt can break the format.
With the Output API: The AI SDK guarantees you a valid, typed object. No parsing, no regex, no any. You define a Zod schema, and result.output has exactly the types you defined.
WALKTHROUGH
Section titled “WALKTHROUGH”Layer 1: Zod schema defines the expectation
Section titled “Layer 1: Zod schema defines the expectation”If not yet installed:
npm install zod. Zod is a TypeScript-first schema validation library — you define the expected structure, and Zod ensures type safety at both compile time and runtime.
A Zod schema describes the structure you expect from the LLM. It defines fields, types and optional descriptions:
import { z } from 'zod';
const recipeSchema = z.object({ name: z.string(), ingredients: z.array(z.string()), steps: z.array(z.string()), prepTime: z.number().describe('Zubereitungszeit in Minuten'), // ← describe() hilft dem LLM});z.describe() is especially useful: It gives the LLM a hint about what a field means. Without a description the LLM has to infer the purpose from the field name — with a description it becomes more precise.
Layer 2: Output.object — connects schema with generateText
Section titled “Layer 2: Output.object — connects schema with generateText”Output.object takes your Zod schema and tells the AI SDK: “I expect an object with this structure.” The result is in result.output:
import { Output, generateText } from 'ai';import { anthropic } from '@ai-sdk/anthropic';
const { output } = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), output: Output.object({ schema: recipeSchema }), // ← Schema verbinden prompt: 'Erstelle ein Rezept fuer Pasta Carbonara.',});
console.log(output.name); // ← string — typisiert!console.log(output.ingredients); // ← string[] — typisiert!console.log(output.prepTime); // ← number — typisiert!No JSON.parse, no as any, no manual casting. TypeScript knows the types because they’re derived from the Zod schema.
Layer 3: Output.array — list of objects
Section titled “Layer 3: Output.array — list of objects”When you need multiple objects of the same type, use Output.array:
const { output } = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), output: Output.array({ element: z.object({ city: z.string(), country: z.string(), population: z.number().describe('Einwohnerzahl'), }), }), prompt: 'Liste 5 Grossstaedte in Europa.',});
// output ist ein typisiertes Arrayfor (const city of output) { console.log(`${city.city}, ${city.country}: ${city.population}`);}Output.array enforces an array of objects. Each element in the array has the structure of the element schema.
Layer 4: Output.choice — choosing from options
Section titled “Layer 4: Output.choice — choosing from options”For classification or simple decisions there’s Output.choice:
const { output } = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), output: Output.choice({ options: ['positive', 'negative', 'neutral'], // ← Erlaubte Werte }), prompt: 'Sentiment: "Das Produkt ist grossartig!"',});
console.log(output); // → 'positive'Output.choice is ideal for routing decisions: The LLM picks an option, and you react based on the choice. No free text parsing, no unreliable includes() checks.
Tip:
z.describe()works at every level — fields, nested objects, arrays. The better your descriptions, the more precise the result. Describe not just WHAT a field is, but also in what FORMAT or RANGE you expect the value.
Task: Extract structured data from a product review. Define a Zod schema and use Output.object.
import { z } from 'zod';import { Output, generateText } from 'ai';import { anthropic } from '@ai-sdk/anthropic';
// TODO 1: Definiere ein Zod Schema fuer eine Produktbewertung// const reviewSchema = z.object({// name: ???, // Produktname// rating: ???, // Bewertung 1-5// pros: ???, // Vorteile (Array)// cons: ???, // Nachteile (Array)// summary: ???, // Zusammenfassung// });
// TODO 2: Nutze Output.object mit dem Schema// const { output } = await generateText({// model: ???,// output: ???,// prompt: 'Bewerte das iPhone 16 Pro. Sei ehrlich und nenne Vor- und Nachteile.',// });
// TODO 3: Logge die typisierten Ergebnisse// console.log('Produkt:', output.name);// console.log('Bewertung:', output.rating);// console.log('Vorteile:', output.pros);// console.log('Nachteile:', output.cons);// console.log('Fazit:', output.summary);Checklist:
- Zod schema defined with at least 4 fields
-
Output.objectused with the schema - Result is typed (no
any) -
z.describe()used for at least one field
Show solution
import { z } from 'zod';import { Output, generateText } from 'ai';import { anthropic } from '@ai-sdk/anthropic';
const reviewSchema = z.object({ name: z.string().describe('Name des Produkts'), rating: z.number().describe('Bewertung von 1 (schlecht) bis 5 (ausgezeichnet)'), pros: z.array(z.string()).describe('Liste der Vorteile'), cons: z.array(z.string()).describe('Liste der Nachteile'), summary: z.string().describe('Zusammenfassung der Bewertung in 1-2 Saetzen'),});
const { output } = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), output: Output.object({ schema: reviewSchema }), prompt: 'Bewerte das iPhone 16 Pro. Sei ehrlich und nenne Vor- und Nachteile.',});
console.log('Produkt:', output.name);console.log('Bewertung:', output.rating, '/ 5');console.log('Vorteile:', output.pros);console.log('Nachteile:', output.cons);console.log('Fazit:', output.summary);Explanation: The Zod schema defines exactly which fields and types you expect. Output.object ensures the LLM generates a valid object that matches this schema. z.describe() gives the LLM additional context — especially for rating it helps to communicate the expected value range.
Run it:
npx tsx challenge-1-5.tsExpected output (approximately):
Product: iPhone 16 ProRating: 4 / 5Pros: [ 'Excellent camera', 'Strong performance', 'Titanium design' ]Cons: [ 'High price', 'Little design change from predecessor' ]Summary: The iPhone 16 Pro impresses with top camera and performance...COMBINE
Section titled “COMBINE”Exercise: Use Output.choice to first determine the category of a text (e.g. “bug-report”, “feature-request”, “question”), then Output.object to extract structured details. Two generateText calls in sequence — the output of the first determines the prompt of the second.
- First call:
Output.choicewith categories like['bug-report', 'feature-request', 'question'] - Second call:
Output.objectwith a schema that fits the detected category - The prompt of the second call contains the category from the first call
Optional Stretch Goal: Use the selectModel function from Challenge 1.2 to choose a cheap flash model for the classification call and a pro model for the detail extraction.