# Type-safe LLM outputs with Zod: stop guessing what the model returns.

> Source: <https://dev.to/thegdsks/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns-544e>
> Published: 2026-07-15 03:34:13+00:00

I shipped a classifier to production in January. The prompt asked for JSON with a single `category`

field. For three weeks it worked fine. Then the model started returning `{"category":"bug","explanation":"this looks like a crash"}`

and the consumer threw a runtime error because it only expected one key. No schema change, no deploy. The model just decided to be helpful.

Zod plus a bit of discipline around the parse step closes that gap. This tutorial walks through defining schemas for LLM output shapes, using them with the Vercel AI SDK and the raw Anthropic SDK, and building a retry loop that handles the cases where the model still gets it wrong.

| Step | What | Why |
|---|---|---|
| Define Zod schema | Describe the shape you want | Single source of truth for your types |
| Use generateText with Output.object | Vercel AI SDK path | Schema-enforced, provider-agnostic |
| Use tool use with tool_choice | Anthropic SDK path | Forces structured output without extra wrappers |
| Parse and retry on failure | ZodError catches drift | Recovers without crashing callers |

Most LLM tutorials show `JSON.parse(response)`

and call it a day. The problem is that the model never agreed to your schema. Ask it to return `{"category": "bug"}`

and it might return:

`{"category": "bug"}`

(correct)`{"Category": "Bug"}`

(wrong casing)`{"category": "bug", "confidence": 0.9}`

(extra field)`{"error": "I cannot classify this"}`

(helpful, but not your schema)Without a parse step that actually validates the shape, every one of those paths silently corrupts downstream data.

The fix is three lines of Zod plus one `.safeParse()`

call. Every technique in this article builds on that pattern, whether you use the Vercel AI SDK, the raw Anthropic SDK, or both.

``` js
import { z } from "zod";

const ClassifyResult = z.object({
  category: z.enum(["bug", "feature", "question"]),
});

type ClassifyResult = z.infer<typeof ClassifyResult>;

// At runtime:
const parsed = ClassifyResult.safeParse(JSON.parse(rawOutput));
if (!parsed.success) {
  // parsed.error is a ZodError with field-level detail
  console.error("Shape violation:", parsed.error.issues);
}
```

Install Zod 4 (currently the stable major):

```
npm install zod@^4.0.0
```

The core APIs (`z.object`

, `z.string`

, `z.enum`

, `z.discriminatedUnion`

, `z.infer`

) all carry over from Zod 3. If you're already on Zod 3, the migration is largely additive for these use cases.

LLM outputs tend to fall into three shapes: flat classifiers, richer extractors, and discriminated results where the model picks a branch. Zod handles all three.

``` js
import { z } from "zod";

export const SentimentSchema = z.object({
  sentiment: z.enum(["positive", "negative", "neutral"]),
  score: z.number().min(-1).max(1),
});

export type Sentiment = z.infer<typeof SentimentSchema>;
js
export const InvoiceSchema = z.object({
  vendor: z.string(),
  amount_usd: z.number().positive(),
  due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  line_items: z.array(
    z.object({
      description: z.string(),
      quantity: z.number().int().positive(),
      unit_price: z.number().positive(),
    })
  ),
});

export type Invoice = z.infer<typeof InvoiceSchema>;
```

This is the shape to reach for when you want the model to pick one of three or more distinct output paths rather than a flat enum.

``` js
export const RoutingResult = z.discriminatedUnion("intent", [
  z.object({
    intent: z.literal("search"),
    query: z.string(),
    filters: z.array(z.string()).optional(),
  }),
  z.object({
    intent: z.literal("create"),
    resource_type: z.string(),
    fields: z.record(z.string(), z.unknown()),
  }),
  z.object({
    intent: z.literal("clarify"),
    question: z.string(),
  }),
]);

export type RoutingResult = z.infer<typeof RoutingResult>;
```

The discriminated union is strict: if `intent`

is `"search"`

, Zod knows to expect `query`

, and a parse attempt with `intent: "create"`

plus a `query`

field fails cleanly.

The Vercel AI SDK added schema-native structured output in recent versions. You pass a Zod schema through `Output.object`

and the SDK handles the prompt scaffolding and parse step for you.

``` js
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const SentimentSchema = z.object({
  sentiment: z.enum(["positive", "negative", "neutral"]),
  score: z.number().min(-1).max(1),
});

async function analyzeSentiment(text: string) {
  const { output } = await generateText({
    model: anthropic("claude-sonnet-4-5-20251022"),
    output: Output.object({ schema: SentimentSchema }),
    prompt: `Analyze the sentiment of this text: "${text}"`,
  });

  // output is typed as { sentiment: "positive" | "negative" | "neutral"; score: number }
  return output;
}

// Usage
const result = await analyzeSentiment("The deploy went sideways at 3am.");
console.log(result.sentiment); // TypeScript knows this is the enum
console.log(result.score);     // TypeScript knows this is a number
```

The return type flows from the schema without any casting. If the model returns something that does not match, the SDK throws before the result reaches your code.

For streaming partial objects as they arrive:

``` js
import { streamText, Output } from "ai";

const { partialOutputStream } = streamText({
  model: anthropic("claude-sonnet-4-5-20251022"),
  output: Output.object({ schema: InvoiceSchema }),
  prompt: "Extract the invoice details from this text: ...",
});

for await (const partial of partialOutputStream) {
  // partial is a Partial<Invoice> as fields arrive
  console.log(partial);
}
```

Add an `onError`

callback for stream errors since they arrive in-band rather than as thrown exceptions.

If you use the Anthropic SDK directly and want schema-enforced output without the Vercel SDK wrapper, the reliable path is tool use with `tool_choice`

forced to your schema tool.

The idea: define a "tool" whose `input_schema`

describes the JSON shape you want. Force the model to call that tool with `tool_choice: { type: "tool", name: "..." }`

. The model then returns a `tool_use`

block with structured input instead of free text. You parse that input with Zod.

``` python
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

// 1. Define your Zod schema
const ClassifyResult = z.object({
  category: z.enum(["bug", "feature", "question"]),
  confidence: z.number().min(0).max(1),
  reasoning: z.string(),
});

type ClassifyResult = z.infer<typeof ClassifyResult>;

// 2. Mirror the schema in JSON Schema for the tool definition
const classifyTool = {
  name: "classify_ticket",
  description: "Classify a support ticket into exactly one category with a confidence score.",
  input_schema: {
    type: "object" as const,
    properties: {
      category: {
        type: "string",
        enum: ["bug", "feature", "question"],
        description: "The category that best fits the ticket.",
      },
      confidence: {
        type: "number",
        description: "Confidence score from 0 to 1.",
      },
      reasoning: {
        type: "string",
        description: "One sentence explaining the classification.",
      },
    },
    required: ["category", "confidence", "reasoning"],
  },
};

async function classifyTicket(text: string): Promise<ClassifyResult> {
  const response = await client.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 512,
    tools: [classifyTool],
    // Force the model to call this specific tool
    tool_choice: { type: "tool", name: "classify_ticket" },
    messages: [
      {
        role: "user",
        content: `Classify this support ticket: "${text}"`,
      },
    ],
  });

  // 3. Extract the tool_use block from the response
  const toolUseBlock = response.content.find((b) => b.type === "tool_use");
  if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
    throw new Error("Model did not return a tool_use block");
  }

  // 4. Validate with Zod
  const parsed = ClassifyResult.safeParse(toolUseBlock.input);
  if (!parsed.success) {
    throw new Error(`Schema violation: ${JSON.stringify(parsed.error.issues)}`);
  }

  return parsed.data;
}
```

The tool_choice parameter with `type: "tool"`

is the key detail. Without it, the model may choose to answer in plain text. With it, the response always comes back as a structured `tool_use`

block.

One practical note: you define the schema twice here, once in Zod and once in JSON Schema. For small schemas that duplication is tolerable. For larger ones, look at `zod-to-json-schema`

on npm to generate the `input_schema`

from your Zod definition automatically.

Even with forced tool use and schema prompting, models occasionally return output that fails validation. Network hiccups, context window pressure, and edge-case inputs all produce unexpected shapes. Build the retry loop before you need it.

```
async function classifyWithRetry(
  text: string,
  maxAttempts = 3
): Promise<ClassifyResult> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await classifyTicket(text);
    } catch (err) {
      lastError = err;
      console.warn(`[classify] attempt ${attempt} failed:`, err);

      if (attempt < maxAttempts) {
        // Small back-off: 500ms, 1000ms, ...
        await new Promise((r) => setTimeout(r, attempt * 500));
      }
    }
  }

  throw new Error(`classify failed after ${maxAttempts} attempts: ${lastError}`);
}
```

When the model returns a close-but-wrong shape, feeding the validation error back in a second call often works better than a blind retry. The model can see what it got wrong and correct it.

```
async function classifyWithRepair(text: string): Promise<ClassifyResult> {
  // First attempt
  const response = await client.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 512,
    tools: [classifyTool],
    tool_choice: { type: "tool", name: "classify_ticket" },
    messages: [{ role: "user", content: `Classify: "${text}"` }],
  });

  const toolBlock = response.content.find((b) => b.type === "tool_use");
  const rawInput = toolBlock?.type === "tool_use" ? toolBlock.input : null;
  const parsed = ClassifyResult.safeParse(rawInput);

  if (parsed.success) {
    return parsed.data;
  }

  // Repair attempt: show the model what it returned and what the schema expects
  const repairMessages = [
    { role: "user" as const, content: `Classify: "${text}"` },
    {
      role: "assistant" as const,
      content: response.content,
    },
    {
      role: "user" as const,
      content: `Your output did not match the schema. Validation errors: ${JSON.stringify(parsed.error.issues)}. Please call classify_ticket again with a valid response.`,
    },
  ];

  const repairResponse = await client.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 512,
    tools: [classifyTool],
    tool_choice: { type: "tool", name: "classify_ticket" },
    messages: repairMessages,
  });

  const repairBlock = repairResponse.content.find((b) => b.type === "tool_use");
  const repaired = ClassifyResult.safeParse(
    repairBlock?.type === "tool_use" ? repairBlock.input : null
  );

  if (!repaired.success) {
    throw new Error(`repair attempt failed: ${JSON.stringify(repaired.error.issues)}`);
  }

  return repaired.data;
}
```

The repair pattern costs one extra call but recovers most edge cases without manual intervention.

``` js
import { z } from "zod";

export const IntentSchema = z.object({
  intent: z.enum(["search", "create", "delete", "status", "help"]),
  confidence: z.number().min(0).max(1),
  extracted_entity: z.string().optional(),
});

export type Intent = z.infer<typeof IntentSchema>;

// JSON Schema for Anthropic tool use
export const intentToolSchema = {
  type: "object" as const,
  properties: {
    intent: {
      type: "string",
      enum: ["search", "create", "delete", "status", "help"],
    },
    confidence: { type: "number" },
    extracted_entity: { type: "string" },
  },
  required: ["intent", "confidence"],
};
js
export const ContactExtractSchema = z.object({
  name: z.string(),
  email: z.string().email().optional(),
  phone: z.string().optional(),
  company: z.string().optional(),
  notes: z.string().optional(),
});

export type ContactExtract = z.infer<typeof ContactExtractSchema>;

// JSON Schema for Anthropic tool use
export const contactExtractToolSchema = {
  type: "object" as const,
  properties: {
    name: { type: "string", description: "Full name of the contact" },
    email: { type: "string", description: "Email address if present" },
    phone: { type: "string", description: "Phone number if present" },
    company: { type: "string", description: "Company or organization if mentioned" },
    notes: { type: "string", description: "Any other relevant details" },
  },
  required: ["name"],
};
```

Usage with the raw Anthropic SDK:

``` js
const extractTool = {
  name: "extract_contact",
  description: "Extract contact information from the provided text.",
  input_schema: contactExtractToolSchema,
};

const response = await client.messages.create({
  model: "claude-haiku-4-5-20251001",
  max_tokens: 512,
  tools: [extractTool],
  tool_choice: { type: "tool", name: "extract_contact" },
  messages: [
    {
      role: "user",
      content: `Extract contact info from: "Hi, I'm Sarah Chen at Acme Corp. Reach me at sarah@acme.io"`,
    },
  ],
});

const block = response.content.find((b) => b.type === "tool_use");
const result = ContactExtractSchema.safeParse(
  block?.type === "tool_use" ? block.input : null
);

if (result.success) {
  console.log(result.data.name);  // "Sarah Chen"
  console.log(result.data.email); // "sarah@acme.io"
}
```

`JSON.parse`

without validation is a time bomb. The model will eventually return a shape you did not expect, and the failure mode is silent data corruption, not a loud error you can catch immediately.

The fix costs two things: a Zod schema (which you should write anyway for TypeScript types) and a `.safeParse()`

call instead of a raw `JSON.parse`

. The Vercel AI SDK with `Output.object`

handles the wiring for you. The raw Anthropic SDK with `tool_choice`

forced to a specific tool gives you the same guarantee with one extra setup step.

The retry and repair patterns are insurance. In practice, with `tool_choice`

forced, parse failures happen in under 1% of calls for well-formed schemas. The 1% still matters when you are running 50,000 classifications a day.

What shape does your most chaotic LLM output have right now? Drop it in the comments.

**GDS K S** · [thegdsks.com](https://thegdsks.com) · follow on X [@thegdsks](https://x.com/thegdsks)

*A Zod schema is the contract the model never gets to break.*
