Module 2 - Lesson 2f: Structured Output with Anthropic
Validated JSON output using Zod with Anthropic Claude.
Published: 1/10/2026
Lesson 2f: Structured Output with Anthropic Claude
Learn how to get reliable, validated JSON responses from Claude using Zod schemas. Unlike OpenAI's native structured output, Anthropic uses a prompt-based approach.
Key Difference from OpenAI
OpenAI: Native structured output with response_format
Anthropic: Prompt-based JSON with Zod validation
Claude doesn't have native structured output (yet), so we request JSON in the prompt and validate it with Zod.
Code Example
Create src/anthropic/structured-output-prompt.ts:
import { Anthropic } from "@anthropic-ai/sdk"; import dotenv from "dotenv"; import { z } from "zod"; // Load environment variables dotenv.config(); const ChristmasMarkets = z.object({ country: z.string().describe("Country where the market is located"), cityMarkets: z .array( z.object({ city: z.string().describe("City where the market is located"), markets: z .array( z.object({ name: z.string().describe("Name of the Christmas market"), description: z .string() .describe("Description of the Christmas market"), address: z.string().describe("Address of the Christmas market"), }) ) .describe("List of Christmas markets in the city"), }) ) .describe("List of cities with their Christmas markets"), }); // Create Anthropic client with typed configuration const anthropic = new Anthropic(); // Async function with proper return type async function structuredOutputPrompt(country: string): Promise<void> { try { console.log("Testing Anthropic connection..."); // Make API call - response is automatically typed! // Using a system prompt along with user prompt // Here we ask for JSON output and then parse it with our Zod schema const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 1000, system: "You are a helpful travel assistant. Extract the relevant Christmas market information based on user input for a country and return information in a structured JSON format.", messages: [ { role: "user", content: `Provide a list of famous Christmas markets in ${country} with a brief description and address for each market. You must respond with valid JSON only. No other text or explanation. The JSON should match this schema: { "country": "string - Country where the market is located", "cityMarkets": [ { "city": "string - City where the market is located", "markets": [ { "name": "string - Name of the Christmas market", "description": "string - Description of the Christmas market", "address": "string - Address of the Christmas market" } ] } ] }`, }, ], }); // Extract text content from response const textContent = response.content.find((block) => block.type === "text"); if (!textContent || textContent.type !== "text") { throw new Error("No text content in response"); } // Parse JSON and validate with Zod schema console.log("Parsing response JSON...", textContent.text); // Remove markdown code block wrapper if present let jsonText = textContent.text.trim(); if (jsonText.startsWith("```json")) { jsonText = jsonText.replace(/^```json\n/, "").replace(/\n```$/, ""); } else if (jsonText.startsWith("```")) { jsonText = jsonText.replace(/^```\n/, "").replace(/\n```$/, ""); } const jsonData = JSON.parse(jsonText); const markets = ChristmasMarkets.parse(jsonData); // TypeScript knows the structure of response console.log("✅ Structured Output Prompt Success!"); console.log("AI Response:"); console.log(JSON.stringify(markets, null, 2)); console.log("Tokens used:"); console.dir(response.usage, { depth: 10 }); } catch (error) { // Proper error handling with type guards if (error instanceof Anthropic.APIError) { console.log("❌ API Error:", error.status, error.message); } else if (error instanceof Error) { console.log("❌ Error:", error.message); } else { console.log("❌ Unknown error occurred"); } } } // Run the test const country = process.argv[2] ?? "Austria"; structuredOutputPrompt(country).catch((error) => { console.error("Error:", error); });
Run It
pnpm tsx src/anthropic/structured-output-prompt.ts Austria
Understanding the Approach
1. Define Zod Schema
First, create your data structure:
import { z } from "zod"; const UserProfile = z.object({ name: z.string(), age: z.number().int().positive(), email: z.string().email(), interests: z.array(z.string()), location: z.object({ city: z.string(), country: z.string() }) }); type UserProfile = z.infer<typeof UserProfile>;
2. Request JSON in Prompt
Tell Claude to return JSON matching your schema:
const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 1000, system: "You are a data extraction assistant. Return only valid JSON.", messages: [{ role: "user", content: `Extract user information and return as JSON: Schema: { "name": "string", "age": "number", "email": "string", "interests": ["array of strings"], "location": { "city": "string", "country": "string" } } Text: John is a 28-year-old developer from London, UK. Email: john@example.com. He enjoys coding, hiking, and photography.` }] });
3. Parse and Validate
Extract JSON and validate with Zod:
const textContent = response.content.find(block => block.type === "text"); if (!textContent || textContent.type !== "text") { throw new Error("No text in response"); } // Remove markdown wrapper if present let jsonText = textContent.text.trim(); if (jsonText.startsWith("```json")) { jsonText = jsonText.replace(/^```json\n/, "").replace(/\n```$/, ""); } else if (jsonText.startsWith("```")) { jsonText = jsonText.replace(/^```\n/, "").replace(/\n```$/, ""); } // Parse and validate const jsonData = JSON.parse(jsonText); const validated = UserProfile.parse(jsonData); // TypeScript now knows the exact shape! console.log(validated.name); // Type-safe!
Handling Claude's Response Formats
Claude may return JSON in different formats:
Format 1: Plain JSON
{"name": "John", "age": 28}
Format 2: Markdown Code Block
```json
{"name": "John", "age": 28}
```
Format 3: With Explanation
Here's the data:
```json
{"name": "John", "age": 28}
### Robust Parsing Function
```typescript
function extractJSON(text: string): string {
let jsonText = text.trim();
// Remove markdown code blocks
if (jsonText.startsWith("```json")) {
jsonText = jsonText.replace(/^```json\n/, "").replace(/\n```$/, "");
} else if (jsonText.startsWith("```")) {
jsonText = jsonText.replace(/^```\n/, "").replace(/\n```$/, "");
}
// Try to find JSON if mixed with text
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonText = jsonMatch[0];
}
return jsonText;
}
// Usage
const textContent = response.content[0].text;
const jsonText = extractJSON(textContent);
const data = JSON.parse(jsonText);
const validated = schema.parse(data);
Practical Examples
1. Data Extraction
const InvoiceData = z.object({ invoiceNumber: z.string(), date: z.string(), total: z.number(), items: z.array(z.object({ description: z.string(), quantity: z.number(), price: z.number() })) }); const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 1000, system: "Extract invoice data as JSON. No other text.", messages: [{ role: "user", content: `Extract data from this invoice: Invoice #INV-2024-001 Date: 2024-01-15 Items: - MacBook Pro (1x) $2,499 - Magic Mouse (2x) $79 each Total: $2,657 Return as JSON matching this schema: { "invoiceNumber": "string", "date": "string", "total": "number", "items": [{"description": "string", "quantity": "number", "price": "number"}] }` }] }); const jsonText = extractJSON(response.content[0].text); const invoice = InvoiceData.parse(JSON.parse(jsonText));
2. Form Data
const ContactForm = z.object({ name: z.string().min(2), email: z.string().email(), message: z.string().min(10), urgency: z.enum(["low", "medium", "high"]) }); const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 500, system: "Extract form data as JSON.", messages: [{ role: "user", content: `Extract contact form data: "Hi, I'm Sarah (sarah@company.com) and I need urgent help with my account. Please contact me ASAP!" Schema: {"name": "string", "email": "string", "message": "string", "urgency": "low|medium|high"}` }] });
3. Complex Nested Data
const Recipe = z.object({ name: z.string(), servings: z.number(), prepTime: z.number(), difficulty: z.enum(["easy", "medium", "hard"]), ingredients: z.array(z.object({ item: z.string(), amount: z.string(), unit: z.string() })), steps: z.array(z.string()), tags: z.array(z.string()) }); const response = await anthropic.messages.create({ model: "claude-sonnet-4-5", // Use Sonnet for complex extraction max_tokens: 2000, system: "Extract recipe data as structured JSON.", messages: [{ role: "user", content: `Extract this recipe as JSON: Chocolate Chip Cookies (Makes 24 cookies, 30 min prep, Easy) Ingredients: - 2 cups all-purpose flour - 1 tsp baking soda - 1 cup butter, softened - 3/4 cup sugar - 2 eggs - 2 cups chocolate chips Steps: 1. Preheat oven to 375°F 2. Mix dry ingredients 3. Cream butter and sugar 4. Add eggs and mix well 5. Combine wet and dry ingredients 6. Fold in chocolate chips 7. Drop spoonfuls on baking sheet 8. Bake for 10-12 minutes Tags: dessert, baking, cookies, chocolate` }] });
Error Handling
Always handle potential parsing errors:
async function getStructuredOutput<T>( schema: z.ZodSchema<T>, prompt: string ): Promise<T> { try { const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 1000, messages: [{ role: "user", content: prompt }] }); const textContent = response.content.find(block => block.type === "text"); if (!textContent || textContent.type !== "text") { throw new Error("No text content in response"); } const jsonText = extractJSON(textContent.text); const jsonData = JSON.parse(jsonText); return schema.parse(jsonData); } catch (error) { if (error instanceof z.ZodError) { console.error("Validation error:", error.errors); throw new Error(`Invalid data structure: ${error.message}`); } else if (error instanceof SyntaxError) { console.error("JSON parsing error"); throw new Error("Invalid JSON in response"); } else { throw error; } } }
Best Practices
1. Be Explicit in Prompts
content: `Return ONLY valid JSON. No explanations, no markdown, no extra text. Schema: ${JSON.stringify(schemaExample, null, 2)} Data to extract: ...`
2. Use System Prompts
system: "You are a JSON extraction bot. Return only valid JSON, never any other text."
3. Provide Schema Examples
content: `Extract as JSON matching this EXACT schema: Example: { "name": "John Doe", "age": 30, "email": "john@example.com" } Now extract from: ...`
4. Validate Early
// Validate as soon as you parse const data = JSON.parse(jsonText); const validated = schema.parse(data); // Throws if invalid
OpenAI vs Anthropic Structured Output
| Feature | OpenAI | Anthropic |
|---|---|---|
| Native support | Yes (response_format) | No (prompt-based) |
| Reliability | Very high | High (with good prompts) |
| Flexibility | Schema in API | Schema in prompt |
| Validation | Built-in | Manual (Zod) |
Key Takeaways
- ✅ Anthropic uses prompt-based JSON (no native structured output yet)
- ✅ Always use Zod for validation
- ✅ Handle markdown code block wrappers
- ✅ Be explicit: "Return ONLY valid JSON"
- ✅ Provide schema examples in prompt
Next Steps
Learn how to give Claude access to external tools and APIs!
Next: Lesson 2g - Function/Tool Calling →
Quick Reference
// Define schema const Schema = z.object({ field: z.string() }); // Request JSON const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 1000, system: "Return only JSON, no other text.", messages: [{ role: "user", content: `Return JSON: {"field": "value"}\nData: ...` }] }); // Parse and validate const text = response.content[0].text; const json = extractJSON(text); const validated = Schema.parse(JSON.parse(json));