Type-safe LLM outputs with Zod: stop guessing what the model returns.
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.
TL;DR
| 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 |
1. The problem: free-form JSON is a contract nobody signed
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) - A markdown fence wrapping the JSON because the model felt polite
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.
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.
2. Defining schemas for LLM output shapes
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.
Flat classifier
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>;
Structured extractor
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>;
Discriminated union for multi-intent routing
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.
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.
3. Vercel AI SDK: generateText with Output.object
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.
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:
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.
4. Raw Anthropic SDK: tool use as structured output
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.
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.
5. Handling parse failures: retry and repair
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.
Simple retry
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}`);
}
Schema repair: feed the error back
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.
6. Two schemas you can copy-paste
Classifier (intent routing for chat)
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"],
};
Extractor (pull structured data from unstructured text)
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:
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"
}
The bottom line
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 ยท follow on X @thegdsks
A Zod schema is the contract the model never gets to break.
Top comments (0)