Skip to content
Learn Agentic AI10 min read0 views

Zod for AI Agent Validation: Schema-First Type-Safe Tool Definitions

Master Zod for building type-safe AI agent tools. Learn how to define schemas for tool inputs, validate LLM-generated arguments, parse structured outputs, and handle validation errors gracefully in TypeScript agent applications.

Why Zod Is Essential for AI Agents

LLMs generate structured output that your code must parse and execute. The model might return a function call with arguments like {"city": "San Francisco", "units": "celsius"} — or it might hallucinate malformed JSON, wrong field names, or invalid types. Without validation, these errors propagate silently into your tool execution layer.

Zod solves this by providing a single schema definition that serves as both runtime validator and TypeScript type generator. Define a schema once, and you get compile-time type checking, runtime validation, and JSON Schema generation for the LLM — all from the same source of truth.

Zod Basics for Tool Schemas

Install Zod:

npm install zod

Define a schema and extract its TypeScript type:

import { z } from "zod";

const WeatherInputSchema = z.object({
  city: z.string().min(1).describe("City name for weather lookup"),
  units: z
    .enum(["celsius", "fahrenheit"])
    .default("celsius")
    .describe("Temperature unit"),
  includeForcast: z
    .boolean()
    .optional()
    .describe("Whether to include a 5-day forecast"),
});

// Extract the TypeScript type automatically
type WeatherInput = z.infer<typeof WeatherInputSchema>;
// Result: { city: string; units: "celsius" | "fahrenheit"; includeForcast?: boolean }

The .describe() calls are critical for AI agents. These descriptions are included in the JSON Schema sent to the LLM, helping the model understand what each parameter expects.

Validating LLM-Generated Arguments

When the LLM returns tool call arguments, validate them before execution:

function executeToolCall(name: string, rawArgs: string) {
  const schemas: Record<string, z.ZodSchema> = {
    get_weather: WeatherInputSchema,
    search_docs: SearchInputSchema,
    create_ticket: TicketInputSchema,
  };

  const schema = schemas[name];
  if (!schema) {
    return { error: `Unknown tool: ${name}` };
  }

  const parsed = schema.safeParse(JSON.parse(rawArgs));

  if (!parsed.success) {
    // Return structured error to the LLM so it can retry
    return {
      error: "Invalid arguments",
      details: parsed.error.issues.map((issue) => ({
        path: issue.path.join("."),
        message: issue.message,
      })),
    };
  }

  // parsed.data is fully typed here
  return toolHandlers[name](parsed.data);
}

Using safeParse instead of parse prevents exceptions from crashing your agent loop. The structured error message can be sent back to the model so it can correct its arguments.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

Generating JSON Schema for LLM Tool Definitions

AI providers expect tool parameters as JSON Schema. Zod can generate this automatically using the zod-to-json-schema package:

import { zodToJsonSchema } from "zod-to-json-schema";

const jsonSchema = zodToJsonSchema(WeatherInputSchema, {
  target: "openAi",
});

// Use in OpenAI tool definition
const tool = {
  type: "function" as const,
  function: {
    name: "get_weather",
    description: "Get current weather for a city",
    parameters: jsonSchema,
  },
};

This eliminates the need to manually write and maintain JSON Schema objects. When you update the Zod schema, the tool definition updates automatically.

Structured Output Parsing

Beyond tool inputs, Zod validates structured outputs from the LLM. When you ask the model to return JSON, validate that the response matches your expected format:

const AnalysisOutputSchema = z.object({
  sentiment: z.enum(["positive", "negative", "neutral"]),
  confidence: z.number().min(0).max(1),
  topics: z.array(z.string()).min(1),
  summary: z.string().max(500),
});

async function analyzeText(text: string) {
  const completion = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content: "Analyze the following text and return JSON with sentiment, confidence, topics, and summary.",
      },
      { role: "user", content: text },
    ],
    response_format: { type: "json_object" },
  });

  const raw = JSON.parse(completion.choices[0].message.content ?? "{}");
  const result = AnalysisOutputSchema.parse(raw);

  return result; // Fully typed: { sentiment, confidence, topics, summary }
}

Complex Schema Patterns for Agents

Real agent tools often need sophisticated schemas. Zod handles unions, recursive types, and transformations:

// Union types for different action kinds
const AgentActionSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("search"),
    query: z.string(),
    filters: z.record(z.string()).optional(),
  }),
  z.object({
    type: z.literal("email"),
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  z.object({
    type: z.literal("schedule"),
    title: z.string(),
    dateTime: z.string().datetime(),
    attendees: z.array(z.string().email()),
  }),
]);

// Transforms to coerce LLM output
const DateRangeSchema = z.object({
  start: z.string().transform((s) => new Date(s)),
  end: z.string().transform((s) => new Date(s)),
}).refine(
  (data) => data.end > data.start,
  { message: "End date must be after start date" }
);

Error Recovery Pattern

When validation fails, feed the error back to the LLM for self-correction:

async function executeWithRetry(
  client: OpenAI,
  messages: ChatCompletionMessageParam[],
  schema: z.ZodSchema,
  maxRetries = 2
): Promise<z.infer<typeof schema>> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const completion = await client.chat.completions.create({
      model: "gpt-4o",
      messages,
      response_format: { type: "json_object" },
    });

    const raw = JSON.parse(completion.choices[0].message.content ?? "{}");
    const result = schema.safeParse(raw);

    if (result.success) return result.data;

    // Append error as context for retry
    messages.push(
      { role: "assistant", content: completion.choices[0].message.content ?? "" },
      {
        role: "user",
        content: `Your response did not match the expected format. Errors: ${JSON.stringify(result.error.issues)}. Please try again.`,
      }
    );
  }

  throw new Error("Failed to get valid structured output after retries");
}

FAQ

Does Zod add significant runtime overhead?

No. Zod validation is extremely fast for the small payloads typical of tool call arguments (microseconds). The overhead is negligible compared to the LLM API latency, which is measured in seconds.

Should I use Zod or JSON Schema directly for tool definitions?

Use Zod as your single source of truth and generate JSON Schema from it. This eliminates the risk of your TypeScript types drifting out of sync with the schema sent to the LLM. The zod-to-json-schema package handles the conversion reliably.

How do I handle optional fields that the LLM might omit?

Use .optional() or .default() in your Zod schema. The .default() approach is usually better for agent tools because it ensures your execute function always receives a complete object without needing null checks.


#Zod #TypeScript #Validation #Schema #AIAgents #TypeSafety #AgenticAI #LearnAI #AIEngineering

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.