Zod v4 + JSON Schema: Runtime Validation for AI Agent Responses

TypeScript types only check at compile time — but LLMs respond at runtime. Zod v4 is 14x faster than v3, ships with built-in .toJSONSchema() for OpenAI Structured Outputs, and enables a retry loop that feeds validation errors back to the model for self-correction. For production AI applications, this runtime safety net is not optional—it's the difference between gracefully handling malformed model output and crashing with an inscrutable TypeError deep in your rendering logic.

The Problem: Compile-Time Types Cannot Protect Runtime

When building AI-powered applications, you cannot rely on TypeScript alone to validate LLM outputs. TypeScript types are erased at compile time and provide zero runtime guarantees. A language model might return malformed JSON with missing closing braces, omit required fields entirely, nest objects where arrays were expected, or produce string values where numbers are required. Without explicit runtime validation, your application either crashes with an unhelpful error or silently accepts bad data and propagates it to downstream systems, databases, or user interfaces. This is particularly acute with LLMs because even the most capable models occasionally produce non-conforming outputs. OpenAI's Structured Outputs feature mitigates this by constraining generation to a provided JSON Schema, but defense-in-depth demands validation anyway.

Solution: Zod v4 with Built-in JSON Schema Support

Zod v4 solves this by enabling a single source of truth. Define your validation schema once in Zod, then export it to JSON Schema for the model, and use the same Zod schema to validate the response at runtime. The .toJSONSchema() method produces a standards-compliant schema compatible with OpenAI, Anthropic, and Google's structured output features:

import { z } from 'zod';

const ResponseSchema = z.object({
  status: z.enum(['success', 'error']),
  data: z.object({
    id: z.string().uuid(),
    timestamp: z.number().positive(),
    items: z.array(z.object({
      name: z.string().min(1).max(200),
      value: z.number().finite()
    })).max(50)
  }).optional(),
  meta: z.object({
    model: z.string(),
    tokensUsed: z.number().int().nonnegative(),
    latencyMs: z.number().positive()
  }).optional()
});

const jsonSchema = ResponseSchema.toJSONSchema();

const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [...],
  response_format: {
    type: "json_schema",
    json_schema: { name: "response", schema: jsonSchema }
  }
});

const parsed = ResponseSchema.parse(JSON.parse(response.choices[0].message.content));

Building a Self-Correction Retry Loop

Zod's error messages are structured and machine-readable, which means you can feed validation failures back to the LLM for self-correction. In practice, this retry pattern resolves 85-95% of validation failures on the second attempt for capable models like GPT-4o and Claude 3.5 Sonnet. The key is that Zod's error messages are specific enough for the model to understand exactly what went wrong: "Expected string at path data.items[3].name, received number" tells the model precisely which field needs correction:

async function generateWithRetry(prompt: string, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const raw = await callLLM(prompt);
    const result = ResponseSchema.safeParse(raw);
    
    if (result.success) return result.data;
    if (attempt === maxRetries) {
      throw new Error(`Validation failed after ${maxRetries} attempts`);
    }
    
    prompt = `${prompt}

Previous response was invalid. Fix:
${result.error.issues.map(i => `- ${i.path.join('.')}: ${i.message}`).join('
')}`;
  }
}

Performance and Cross-Provider Compatibility

Zod v4 processes approximately 700K validations per second—a 14x improvement over v3. For applications processing hundreds of AI-generated responses per second, this means validation adds roughly 0.15ms of latency per response versus 2ms with v3. Tree-shaking support via explicit imports reduces bundle sizes for edge-deployed validators in Cloudflare Workers or Vercel Edge Functions. Beyond OpenAI, the Zod + JSON Schema approach works with Anthropic's tool use (JSON Schema-defined input schemas), Google's Gemini controlled generation, and local models via Ollama or vLLM where you include the JSON Schema in the system prompt. The validation layer remains identical across all providers, giving you provider-agnostic output reliability.

…Back to all articles

Comparing Zod with Competitors: Valibot and ArkType

Zod is not alone in the TypeScript validation space. Valibot offers a tree-shakeable alternative with a similar API but significantly smaller bundle sizes (under 1KB for basic schemas vs Zod's ~12KB). Valibot achieves this through modular imports where each validation function is independently importable, allowing bundlers to eliminate unused validators. ArkType takes a different approach with a constraint-based type syntax that defines both TypeScript types and runtime validators from a single string expression: type('string | number[]') generates both the type and validator simultaneously. For AI applications, Zod's advantage is its ecosystem maturity: more community examples of retry loops, broader integration with LLM frameworks, and the most comprehensive .toJSONSchema() implementation that handles edge cases like unions, discriminated unions, and recursive schemas correctly. Valibot and ArkType are catching up rapidly but currently have less community documentation specifically for AI/LLM integration patterns.

Security Hardening: Preventing Prompt Injection via Schema

When LLM outputs feed directly into downstream systems, validation is a security boundary, not just a data quality check. A malicious prompt could instruct the model to output {"sql": "DROP TABLE users;--"} or {"redirect": "javascript:alert(document.cookie)"}. Zod schemas should include content security constraints: z.string().regex(/^[a-zA-Z0-9_]+$/) for identifiers, z.string().url().refine(url => url.protocol === 'https:') for URLs, and z.string().max(1000) to prevent prompt stuffing attacks where the model echoes injected content into response fields. For free-text fields that may legitimately contain special characters, consider z.string().refine(s => !s.includes('') && !s.includes('javascript:')) as a lightweight XSS guard. The schema acts as an output firewall—every field that passes validation is guaranteed safe for its intended use context.

You May Also Like