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

FeatureOpenAIAnthropic
Native supportYes (response_format)No (prompt-based)
ReliabilityVery highHigh (with good prompts)
FlexibilitySchema in APISchema in prompt
ValidationBuilt-inManual (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));