Chapter 5: Validating LLM Outputs with Zod
Theoretical Foundations
Imagine you are the manager of a bustling international newsroom. Your job is to publish breaking news articles that are 100% accurate and formatted correctly for your website. You have a brilliant but unpredictable freelance journalist (the LLM) who is stationed in a foreign country and sends back reports via a chaotic telegram system. This journalist is incredibly fast and knowledgeable, but they are not a native speaker of your newsroom's language, and they sometimes include rumors, typos, or ambiguous phrasing in their reports. If you publish their raw telegram directly to your front page, you risk broadcasting misinformation, breaking your website's layout, or even causing legal issues.
This is the fundamental challenge of building applications with Large Language Models. The LLM is a powerful tool for generating data, but its output is inherently unstructured and unreliable. It's the "unreliable messenger."
In the previous chapter, we discussed how to structure the LLM's instructions using Few-Shot Prompting. This is like giving your journalist a strict template and examples of perfect articles to follow. It significantly improves the quality of the raw telegram, but it doesn't guarantee perfection. The journalist might still have a bad day or misinterpret a subtle instruction.
Validating LLM Outputs with Zod is the process of installing a "Trusted Translator" in your newsroom. This translator takes the raw, unpredictable telegram from the journalist, reads it, and verifies every single piece of information against a strict set of rules (the schema). Only after the translator confirms that the report is accurate, well-formed, and complete does it convert it into your newsroom's official, perfectly structured digital format. This final, validated document is what you publish.
In technical terms, the LLM generates a raw string (often JSON) that resembles a structured object. Zod acts as this runtime validation layer, a "contract" that defines the exact shape, types, and constraints of the data your application expects. It inspects the LLM's output, and if it matches the contract, it transforms it into a strongly-typed TypeScript object. If it doesn't match, it throws a clear, actionable error, preventing corrupted data from ever entering your application's core logic.
The "Why": Beyond Type Safety - The Defensive Wall
The necessity of this validation layer stems from the fundamental nature of LLMs and the principles of robust software engineering.
-
Preventing Runtime Errors and Application Crashes: A frontend component expects a user's profile to have a
nameproperty of typestring. If the LLM, for some reason, returns a profile object wherenameisnullor a number, attempting to renderuser.name.toUpperCase()will throw a runtime error, crashing the component. This is especially critical in a tRPC or Edge Function context, where an unhandled error can terminate the entire API request, returning a 500 error to the client. Zod ensures that the data structure is exactly as expected, making such runtime errors impossible. -
Combating Hallucinations and Data Drift: An LLM can "hallucinate" – confidently inventing facts or data. For example, if you ask it to generate a list of a user's recent transactions, it might invent a transaction with a negative amount or a date in the future. A Zod schema can enforce business logic rules directly at the data ingestion point:
This acts as a defensive wall, ensuring that only valid, business-rule-compliant data passes through to your database or frontend. -
Enabling Reliable Type Inference for Developer Productivity: This is where the magic of TypeScript's Type Inference shines. When you define a Zod schema, you are not just creating a runtime validator; you are creating a single source of truth for both runtime and compile-time types. Zod can infer the corresponding TypeScript type directly from the schema definition. This eliminates the need to maintain two separate definitions (one for runtime validation, one for static types), drastically reducing boilerplate and preventing them from falling out of sync. This is a direct evolution of the principles we saw in earlier chapters when we defined tRPC procedures, where input and output schemas automatically provided type safety to our client-side queries.
-
Building Resilient and Composable APIs: In a microservices architecture, services must communicate via well-defined, strict contracts. An LLM is a "service" that produces data for other parts of your application. Treating its output as a formal contract is essential for building resilient systems. When you integrate an LLM call within a tRPC procedure, the Zod-validated output becomes the guaranteed return type of that procedure. This means any client consuming this API (a React component, a mobile app, another backend service) can trust the data structure implicitly, leading to more stable and predictable systems.
The "How": The Validation Pipeline
The process of validating an LLM's output with Zod can be broken down into a clear, sequential pipeline.
Step 1: Schema Definition (The Contract) First, you define the contract. This is a declarative schema using Zod's API. It describes the exact shape of the data you expect. This is the blueprint for your Trusted Translator.
import { z } from 'zod';
// Define the contract for a user profile generated by an LLM
const UserProfileSchema = z.object({
username: z.string().min(3).max(20),
bio: z.string().optional(),
isVerified: z.boolean(),
metadata: z.object({
joinDate: z.string().datetime(),
postCount: z.number().int().nonnegative(),
}),
});
Step 2: Raw Output Generation (The Telegram) Next, you call the LLM. You use Few-Shot Prompting (from the previous chapter) to guide it towards generating a JSON string that conforms to your schema. The output at this stage is just a raw string.
// This is a conceptual call to an LLM service
const llmResponse = await llm.generate(`
Generate a user profile in JSON format matching this structure:
{ "username": "...", "bio": "...", "isVerified": ..., "metadata": {...} }
Example:
{ "username": "dev_guru", "bio": "Loves building things", "isVerified": true, "metadata": { "joinDate": "2023-10-27T10:00:00Z", "postCount": 42 } }
`);
// llmResponse is a string: '{"username": "new_user", "bio": null, "isVerified": "true", "metadata": {"joinDate": "invalid-date", "postCount": 5}}'
isVerified is a string "true", not a boolean true, and joinDate is malformed. This is where validation is critical.
Step 3: Parsing and Validation (The Translation)
This is the core of the process. You pass the raw LLM string to the Zod schema's .parse() method. Zod attempts to parse the data, coercing types where possible (like parsing a string "true" into a boolean) and validating all constraints.
try {
// First, parse the raw string into a JavaScript object
const rawObject = JSON.parse(llmResponse);
// Now, validate the object against the schema
const validatedProfile = UserProfileSchema.parse(rawObject);
// If execution reaches here, the data is guaranteed to be correct.
// `validatedProfile` is now a fully typed object.
console.log(validatedProfile.username.toUpperCase()); // Safe! Type is string.
} catch (error) {
// If validation fails, Zod throws a detailed error.
// This error can be logged, sent to a monitoring service, or trigger a retry.
console.error("LLM output failed validation:", error.errors);
}
Step 4: Integration (Publishing the Article) The validated, strongly-typed object is now ready to be used safely throughout the rest of your application. In a tRPC procedure, this validated object is returned as the procedure's result. In an Edge Function, it can be stored in a database or sent to a client. The key is that by this point, all uncertainty has been eliminated.
Visualizing the Data Flow
The following diagram illustrates the journey of data from the LLM to your application's core logic, highlighting the critical validation step.
The Role of Asynchronous Tool Handling
When an LLM decides to use a tool (like calling a weather API or querying a database), the process becomes more complex. The LLM doesn't perform the action itself; it generates a structured request for your application to execute. This is where the concept of Asynchronous Tool Handling becomes paramount.
Think of the LLM as a project manager and your application as a team of specialists. The manager (LLM) can't do the specialists' work but can assign tasks. The specialists (tools) must perform their work without blocking the entire project.
- LLM Generates a Tool Call Request: The LLM's output is not a final answer but a command to execute a tool. This is often a structured object like
{ "tool": "get_weather", "arguments": { "city": "London" } }. - Application Executes the Tool Asynchronously: Your application code must recognize this request, call the external API (e.g.,
fetch('https://api.weather.com/...')), andawaitthe result. This is a non-blocking operation. The entire application isn't frozen while waiting for the weather API. - Result is Fed Back to the LLM: The result from the tool is then formatted and sent back to the LLM, often in a new message, to provide context for the final answer.
This is a perfect example of why validation is a multi-stage process. You need a Zod schema to validate the LLM's initial tool call request, and potentially another schema to validate the data returned from the external API before feeding it back to the LLM.
The Web Development Analogy: API Gateways
In modern web development, especially with microservices, an API Gateway is a common pattern. It acts as a single entry point for all client requests. The gateway's responsibilities include:
- Routing: Directing requests to the correct backend service.
- Authentication & Authorization: Verifying the client's identity and permissions.
- Request/Response Transformation: Modifying data formats to match what the client or service expects.
- Rate Limiting and Caching: Protecting backend services from being overwhelmed.
Zod validation for LLM outputs is the API Gateway for your application's data layer.
The LLM is a backend service (a "microservice for intelligence"). It provides data, but it doesn't conform to your internal application's strict API standards. The Zod validation layer acts as the gateway:
- It receives the raw, untrusted data from the LLM service.
- It validates the request against a strict schema (like checking an API key).
- It transforms the data, coercing types and ensuring structure (like transforming a date format).
- It either forwards the clean, validated data to your application's core logic or rejects the request with a clear error (like a
400 Bad Request).
Without this gateway (the Zod schema), your application's core logic would be directly exposed to the unpredictable nature of the LLM service, leading to chaos and instability. By enforcing a strict contract at the boundary, you build a resilient, predictable, and type-safe system that can reliably leverage the power of LLMs.
Basic Code Example
In the context of SaaS and web applications, Large Language Models (LLMs) are often used as intelligent engines to generate structured data, such as user profiles, product descriptions, or API responses. However, LLMs are inherently probabilistic and can "hallucinate" or output malformed JSON, inconsistent types, or missing required fields. Relying on this raw output directly in your application leads to runtime errors, broken UI components, and security vulnerabilities.
Zod solves this by providing a schema-based validation library that infers TypeScript types directly from the validation schema. This ensures that the data flowing from the LLM into your application is strictly typed and validated at runtime, creating a robust boundary between the unpredictable AI and your deterministic code.
Basic Code Example: Validating a User Profile
Below is a self-contained TypeScript example simulating a backend service that generates a user profile from an LLM and validates it using Zod before returning it to the frontend.
import { z } from "zod";
/**
* SECTION 1: SCHEMA DEFINITION
* We define the shape of the data we expect the LLM to produce.
* This acts as both a runtime validator and a TypeScript type definition.
*/
// Define the schema for a User Profile
const UserProfileSchema = z.object({
id: z.string().uuid(), // Must be a valid UUID string
username: z.string().min(3).max(20), // Must be a string between 3 and 20 chars
email: z.string().email(), // Must be a valid email format
preferences: z.array(z.string()), // Must be an array of strings
metadata: z.object({
lastLogin: z.string().datetime(), // ISO 8601 date string
isActive: z.boolean(), // Must be a boolean
}),
});
// Infer the TypeScript type from the schema for internal use
type UserProfile = z.infer<typeof UserProfileSchema>;
/**
* SECTION 2: MOCK LLM RESPONSE
* In a real scenario, this would be an API call to OpenAI, Anthropic, etc.
* We simulate a "dirty" response that contains type mismatches and extra fields.
*/
const mockRawLLMResponse: unknown = {
id: "123e4567-e89b-12d3-a456-426614174000", // Valid UUID
username: "alex_dev", // Valid
email: "alex@example.com", // Valid
preferences: ["dark_mode", "notifications", 123], // ERROR: Array contains a number
metadata: {
lastLogin: "2023-10-27T10:00:00Z", // Valid ISO string
isActive: "true", // ERROR: String "true" instead of boolean true
},
// Extra field not in schema (Zod strips this by default in strict mode or .parse)
internal_token: "secret_llm_token",
};
/**
* SECTION 3: VALIDATION LOGIC
* This function acts as the "Type Guard" for the LLM output.
*/
function validateAndTransformLLMOutput(
rawOutput: unknown
): UserProfile | null {
try {
// .parse() throws an error if validation fails
// This enforces strict typing at runtime
const validatedData = UserProfileSchema.parse(rawOutput);
console.log("✅ Validation successful. Data is safe to use.");
return validatedData;
} catch (error) {
if (error instanceof z.ZodError) {
// Log specific validation errors for debugging
console.error("❌ Validation failed:", error.errors);
} else {
console.error("❌ An unexpected error occurred:", error);
}
return null;
}
}
/**
* SECTION 4: EXECUTION
* Simulating the flow in a backend handler (e.g., tRPC procedure or Edge Function).
*/
(async () => {
console.log("--- Starting LLM Output Validation ---");
// 1. Receive raw data from LLM
const result = validateAndTransformLLMOutput(mockRawLLMResponse);
// 2. Use the validated data (TypeScript now knows 'result' is UserProfile | null)
if (result) {
// TypeScript allows access to specific properties safely
console.log(`Welcome back, ${result.username}`);
console.log(`Preferences: ${result.preferences.join(", ")}`);
} else {
console.log("Please try generating the profile again.");
}
})();
Line-by-Line Explanation
1. Schema Definition
import { z } from "zod";: Imports the Zod library.const UserProfileSchema = z.object({ ... }): Creates a schema object. This is the blueprint for our data.id: z.string().uuid(): Chains validators. First checks if it's a string, then checks if it matches the UUID format.username: z.string().min(3).max(20): Enforces length constraints, preventing empty or overly long usernames.preferences: z.array(z.string()): Ensures the field is an array where every element is a string. This is crucial;[1, 2]would fail here.type UserProfile = z.infer<typeof UserProfileSchema>: This is Type Inference. We don't write the interface manually. Zod analyzes the schema and generates the corresponding TypeScript type ({ id: string; ... }). If you update the schema, the type updates automatically.
2. Mock LLM Response
const mockRawLLMResponse: unknown: We type this asunknownbecause we cannot trust the LLM. We treat it as opaque data until validated.- The Data: Note the intentional errors:
preferences: Contains the number123. The schema expectsz.array(z.string()), so this will fail.metadata.isActive: Contains the string"true". The schema expects a booleantrue.internal_token: An extra field. By default, Zod's.parse()will strip this unknown field (depending on strictness settings, but usually strips unknown keys in object parsing).
3. Validation Logic
function validateAndTransformLLMOutput(...): A wrapper function to isolate the validation logic.UserProfileSchema.parse(rawOutput): This is the core operation.- It runs the runtime check.
- If valid, it returns the data typed as
UserProfile. - If invalid, it throws a
z.ZodError.
catch (error): We catch the error to handle validation failures gracefully (e.g., asking the LLM to retry) rather than crashing the server.error.errors: Zod provides detailed error objects indicating which field failed and why (e.g., "Expected string, received number").
4. Execution
(async () => { ... })(): An Immediately Invoked Function Expression (IIFE) to simulate an asynchronous backend environment.if (result): Type narrowing. TypeScript knows that inside this block,resultis of typeUserProfile(not null), so we can safely accessresult.usernamewithout optional chaining (?.).
Visualizing the Data Flow
The following diagram illustrates how Zod acts as a firewall between the LLM and your application logic.
Common Pitfalls
When implementing LLM validation in production SaaS environments, be aware of these specific issues:
-
Hallucinated JSON Structure
- Issue: LLMs often return valid JSON but with the wrong top-level structure (e.g., returning a string "Here is the data: { ... }" instead of the object
{ ... }). - Solution: Use a "pre-processing" step before Zod. Attempt to
JSON.parse()the raw string. If the LLM returns a markdown code block (e.g.,json ...), strip the backticks before passing it to Zod.
- Issue: LLMs often return valid JSON but with the wrong top-level structure (e.g., returning a string "Here is the data: { ... }" instead of the object
-
Vercel/AWS Lambda Timeouts
- Issue: Zod validation is synchronous. If you are validating a massive array of objects (e.g., 10,000 items returned by an LLM), the event loop is blocked, potentially causing serverless timeouts.
- Solution: For large datasets, use
z.array(schema).parseAsync()or validate in batches within a background job (e.g., Vercel Background Functions) rather than the main request thread.
-
Async/Await Loops with LLMs
- Issue: When chaining multiple LLM calls (e.g., generate draft -> validate -> regenerate if failed), developers often create infinite loops if the validation error isn't handled correctly.
- Solution: Implement a strict retry limit (e.g., max 3 attempts). If Zod fails after the limit, return a structured error to the client rather than retrying indefinitely.
-
Overly Strict vs. Loose Schemas
- Issue: Defining a schema that is too strict (e.g., exact string literals) can cause valid LLM outputs to fail because the LLM used a synonym (e.g., "Premium" vs "Pro").
- Solution: Use Zod's
.catchall(z.never())or.passthrough()carefully. Alternatively, usez.enum()with a wider range of acceptable values or preprocess strings with.transform()to normalize casing (e.g.,.transform(val => val.toLowerCase())).
-
Type Inference Mismatch
- Issue: Manually defining a TypeScript interface that doesn't match the Zod schema. If you update the schema but forget the interface, TypeScript will lie to you at compile time, but runtime will crash.
- Solution: Never manually write the interface for an API response. Always use
z.infer<typeof schema>. This guarantees that your compile-time types match your runtime validation 100% of the time.
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.