Claude API Response Validation with Zod
When you call the Claude API, you get back a JSON object whose shape is mostly stable but not guaranteed at every field — content blocks vary by type, tool use arguments are model-generated, and streaming events arrive as partial fragments. If your code assumes a fixed shape and just does response.content[0].text, one unexpected block type or empty array will crash your app in production. Zod solves this by giving you runtime schema validation with full TypeScript inference, so you catch malformed or unexpected responses at the boundary instead of three function calls deep.
This article shows how to build Zod schemas for Claude API responses, validate both plain text and tool-use outputs, and handle the parts of the response that are inherently unpredictable — like model-generated JSON inside tool calls.
Why validate Claude API responses at all
TypeScript types only exist at compile time. The Anthropic SDK's type definitions tell you what the response should look like, but they don't check what actually came back over the wire. Two real scenarios where this bites:
- Tool use arguments: the model generates a JSON object matching your tool's input schema, but it's still model output — a missing field, wrong type, or malformed structure is possible.
- Content block arrays: a response can contain
text,tool_use, or (with extended thinking) other block types. Code that assumescontent[0]is always text will fail when the model returns a tool call first.
Zod lets you parse the response, get a typed and guaranteed-correct object back, or fail fast with a readable error instead of a silent undefined.
Basic schema for a Claude response
Start with the top-level response shape. This mirrors what you get from the Messages API, whether you're calling Anthropic directly or through a proxy like SubToAPI.
import { z } from "zod";
const TextBlock = z.object({
type: z.literal("text"),
text: z.string(),
});
const ToolUseBlock = z.object({
type: z.literal("tool_use"),
id: z.string(),
name: z.string(),
input: z.record(z.unknown()),
});
const ContentBlock = z.union([TextBlock, ToolUseBlock]);
const ClaudeResponse = z.object({
id: z.string(),
model: z.string(),
role: z.literal("assistant"),
content: z.array(ContentBlock).min(1),
stop_reason: z.enum([
"end_turn",
"max_tokens",
"stop_sequence",
"tool_use",
]),
usage: z.object({
input_tokens: z.number(),
output_tokens: z.number(),
}),
});
type ClaudeResponse = z.infer<typeof ClaudeResponse>;
Now parsing is a single call:
const raw = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(payload),
}).then((r) => r.json());
const result = ClaudeResponse.safeParse(raw);
if (!result.success) {
console.error(result.error.format());
throw new Error("Unexpected Claude response shape");
}
const message = result.data; // fully typed, guaranteed valid
Using safeParse instead of parse avoids throwing inside a hot path — you decide how to log or recover from a validation failure instead of an uncaught exception taking down a request.
Validating tool-use arguments specifically
The riskiest part of any Claude integration is trusting input on a tool_use block, since that object is generated by the model, not by your code. Define a schema per tool and validate it separately from the envelope:
const CreateTicketInput = z.object({
title: z.string().min(1),
priority: z.enum(["low", "medium", "high"]),
assignee: z.string().email().optional(),
});
function handleToolUse(block) {
const parsed = CreateTicketInput.safeParse(block.input);
if (!parsed.success) {
// Return this back to Claude as a tool_result error instead of crashing
return { error: parsed.error.flatten() };
}
return createTicket(parsed.data);
}
This pattern also lets you feed validation errors back into the conversation as a tool_result with is_error: true, so the model can retry with corrected arguments instead of your process failing silently. See /docs/tools for the tool-result format if you're wiring this into a multi-turn agent loop.
Handling streaming events
Streaming responses arrive as a sequence of SSE events (message_start, content_block_delta, message_stop, etc.), each with its own shape. Validate each event type independently rather than trying to force one giant schema:
const ContentBlockDelta = z.object({
type: z.literal("content_block_delta"),
index: z.number(),
delta: z.object({
type: z.literal("text_delta"),
text: z.string(),
}),
});
function handleEvent(event) {
if (event.type === "content_block_delta") {
const parsed = ContentBlockDelta.parse(event);
process.stdout.write(parsed.delta.text);
}
// handle other event types with their own schemas
}
If you're consuming streamed output through a wrapper API, the event structure should already be normalized — SubToAPI, for example, exposes the same SSE format described in /docs/streaming, so the same Zod schemas work whether you're calling the raw provider or a proxied endpoint.
Structured output as a shortcut
If you control the prompt, you can ask Claude to always return a specific JSON structure and then validate that structure directly with the same tool schema, rather than parsing free text. This is more reliable than regex-extracting JSON from a text block and pairs naturally with Zod:
const responseText = message.content.find((b) => b.type === "text")?.text;
const parsedJson = JSON.parse(responseText);
const validated = MyOutputSchema.parse(parsedJson);
Wrap the JSON.parse in a try/catch — models occasionally wrap JSON in markdown fences or add a trailing sentence, and you want that to surface as a validation error, not an unhandled exception.
Where this fits with a wrapper API
If you're routing Claude calls through SubToAPI to get a stable HTTPS endpoint, application-scoped keys, and usage metadata across a team, the response envelope from /v1/messages follows the same Messages API shape, so every schema in this article works unchanged — you're validating the same content, stop_reason, and usage fields. Check /docs/messages for the exact response reference, or /docs/quickstart if you're setting up a key for the first time.
Practical checklist
- Validate the top-level envelope once, close to the fetch call
- Validate each tool's
inputwith its own schema, not a genericz.record - Use
safeParsein request paths,parsein scripts where throwing is fine - Feed tool-input validation failures back to Claude as
tool_resulterrors so it can self-correct - Validate streaming events per event type, not as one union schema
questions
Does Zod slow down request handling noticeably? No — Zod validation on a single API response object takes well under a millisecond. It's negligible compared to the network round trip to the model.
Should I validate on every request or only in development? Validate in every environment. Production is exactly where an unexpected response shape (rate limit body, proxy error page, malformed tool input) will cost you the most if it goes unhandled.
Can I generate Zod schemas automatically from a tool's JSON Schema? There are libraries that convert JSON Schema to Zod, but for tool inputs it's usually cleaner to hand-write the Zod schema alongside the tool definition — you get better error messages and can add refinements JSON Schema doesn't express well, like cross-field validation.