Skip to main content

Zod Schemas for TypeScript: Type-Safe LLM Outputs

Zod is the TypeScript equivalent of Pydantic: a schema validation library that combines runtime safety with compile-time type inference. For LLM applications, Zod schemas ensure that JSON responses match your expected structure while providing IDE autocomplete and type checking across your codebase. Define your schema once and get both validation and types automatically.

Why Zod for LLM Integration

TypeScript applications need runtime validation because JSON data from the API lacks type information. Without validation, a number field might unexpectedly be a string, breaking downstream code. Zod solves this by validating at runtime and providing TypeScript types.

Without Zod (manual type assertion):

const response = await openai.chat.completions.create({
model: "gpt-4-turbo",
messages: [{ role: "user", content: "Extract sentiment..." }],
response_format: { type: "json_schema", json_schema: schema }
});

// Manual parsing (unsafe)
const data = JSON.parse(response.choices[0].message.content);
const sentiment = data.sentiment; // TypeScript thinks this is 'any'
const confidence = data.confidence; // No type safety

With Zod (validated + typed):

import { z } from "zod";

const SentimentSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
explanation: z.string().max(200)
});

type Sentiment = z.infer<typeof SentimentSchema>;

const response = await openai.chat.completions.create(...);
const sentiment: Sentiment = SentimentSchema.parse(
JSON.parse(response.choices[0].message.content)
);
// Now sentiment.sentiment is known to be "positive" | "negative" | "neutral"
// IDE autocomplete works

Basic Zod Schemas

A Zod schema is a validator that enforces type and constraint rules.

import { z } from "zod";

// Simple schema with type constraints
const PersonSchema = z.object({
name: z.string(),
age: z.number().int().min(0).max(150),
email: z.string().email()
});

// Extract type from schema (no duplication!)
type Person = z.infer<typeof PersonSchema>;

// Use it
const data = { name: "Alice", age: 30, email: "[email protected]" };
const person: Person = PersonSchema.parse(data);

// Invalid data throws ZodError
try {
PersonSchema.parse({ name: "Bob", age: -5, email: "bad-email" });
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors);
// [
// { path: ["age"], message: "Number must be greater than or equal to 0" },
// { path: ["email"], message: "Invalid email" }
// ]
}
}

Generating JSON Schema from Zod

Zod can generate JSON Schema compatible with LLM APIs:

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

const SentimentSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
explanation: z.string().max(200)
});

// Convert to JSON Schema
const jsonSchema = zodToJsonSchema(SentimentSchema);
console.log(jsonSchema);
// Output:
// {
// "type": "object",
// "properties": {
// "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
// "confidence": {"type": "number", "minimum": 0, "maximum": 1},
// "explanation": {"type": "string", "maxLength": 200}
// },
// "required": ["sentiment", "confidence", "explanation"]
// }

Install zod-to-json-schema:

npm install zod-to-json-schema

Using Zod with OpenAI API

Combine Zod schema generation with OpenAI's JSON Mode:

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

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// Define schema with Zod
const ProductReviewSchema = z.object({
productName: z.string(),
rating: z.number().int().min(1).max(5),
positivePoints: z.array(z.string()).max(5),
negativePoints: z.array(z.string()).max(5),
recommendation: z.boolean()
});

type ProductReview = z.infer<typeof ProductReviewSchema>;

async function extractReview(reviewText: string): Promise<ProductReview> {
const response = await client.chat.completions.create({
model: "gpt-4-turbo",
messages: [
{
role: "user",
content: `Extract review info from: "${reviewText}"`
}
],
response_format: {
type: "json_schema",
json_schema: {
name: "ProductReview",
schema: zodToJsonSchema(ProductReviewSchema),
strict: true
}
}
});

// Parse and validate with Zod
const jsonContent = response.choices[0].message.content;
const parsed = JSON.parse(jsonContent);
return ProductReviewSchema.parse(parsed);
}

// Use it with full type safety
const review = await extractReview("Great product but expensive");
console.log(review.productName); // TypeScript knows this is a string
console.log(review.rating); // TypeScript knows this is 1-5

Optional Fields and Defaults

Use .optional() or .nullable() for optional/nullable fields:

import { z } from "zod";

const ArticleSchema = z.object({
title: z.string(),
content: z.string(),
author: z.string().default("Anonymous"),
tags: z.array(z.string()).optional(),
publishedAt: z.string().datetime().nullable()
});

type Article = z.infer<typeof ArticleSchema>;

// Valid with optional/default fields missing
const article: Article = {
title: "Learning TypeScript",
content: "TypeScript is great...",
// author defaults to "Anonymous"
// tags is undefined
// publishedAt is null
};

Enums and Discriminated Unions

Zod's z.enum() enforces fixed values. For complex types, use unions:

import { z } from "zod";

// Simple enum
const SentimentSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1)
});

// Discriminated union (different fields based on sentiment)
const DetailedSentimentSchema = z.discriminatedUnion("sentiment", [
z.object({
sentiment: z.literal("positive"),
positiveAspects: z.array(z.string()),
confidence: z.number()
}),
z.object({
sentiment: z.literal("negative"),
negativeAspects: z.array(z.string()),
confidence: z.number()
}),
z.object({
sentiment: z.literal("neutral"),
confidence: z.number()
})
]);

type DetailedSentiment = z.infer<typeof DetailedSentimentSchema>;
// TypeScript knows the shape based on the sentiment value

Nested Zod Schemas

Compose schemas for complex data structures:

import { z } from "zod";

const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zipCode: z.string().optional()
});

const PersonSchema = z.object({
name: z.string(),
email: z.string().email(),
address: AddressSchema,
phoneNumbers: z.array(z.string()).default([])
});

type Person = z.infer<typeof PersonSchema>;

const person: Person = {
name: "Alice",
email: "[email protected]",
address: {
street: "123 Main St",
city: "NYC",
country: "USA"
}
};

Arrays and Collections

Use z.array() for arrays, with size constraints:

import { z } from "zod";

const EntityExtractionSchema = z.object({
entities: z.array(
z.object({
name: z.string(),
type: z.enum(["person", "organization", "location"]),
confidence: z.number().min(0).max(1)
})
).min(1).max(50) // At least 1, at most 50 entities
});

type EntityExtraction = z.infer<typeof EntityExtractionSchema>;

Validating LLM Responses

Use .safeParse() for graceful error handling:

import { z } from "zod";

const schema = z.object({
status: z.enum(["success", "error"]),
data: z.string().optional()
});

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

// Validate safely
const result = schema.safeParse(llmResponse);

if (result.success) {
console.log(result.data); // result.data has correct type
} else {
console.error("Validation failed:", result.error.errors);
// Handle error: return default, retry, or escalate
}

Real-World Example: Multi-Step LLM Pipeline with Zod

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

const client = new OpenAI();

// Step 1: Classify support ticket
const TicketClassificationSchema = z.object({
category: z.enum(["bug", "feature", "support", "billing"]),
priority: z.enum(["low", "medium", "high", "critical"]),
assignee: z.string().optional()
});

type TicketClassification = z.infer<typeof TicketClassificationSchema>;

// Step 2: Suggest resolution
const ResolutionSchema = z.object({
suggestedSolution: z.string(),
estimatedTime: z.string(),
requiresEscalation: z.boolean()
});

type Resolution = z.infer<typeof ResolutionSchema>;

async function processTicket(ticketText: string) {
// Step 1: Classify
const classifyResponse = await client.chat.completions.create({
model: "gpt-4-turbo",
messages: [{ role: "user", content: `Classify ticket: "${ticketText}"` }],
response_format: {
type: "json_schema",
json_schema: {
name: "TicketClassification",
schema: zodToJsonSchema(TicketClassificationSchema),
strict: true
}
}
});

const classification: TicketClassification = TicketClassificationSchema.parse(
JSON.parse(classifyResponse.choices[0].message.content)
);

// Step 2: Suggest resolution
const resolutionResponse = await client.chat.completions.create({
model: "gpt-4-turbo",
messages: [
{
role: "user",
content: `Ticket category: ${classification.category}\n\nSuggest solution for: "${ticketText}"`
}
],
response_format: {
type: "json_schema",
json_schema: {
name: "Resolution",
schema: zodToJsonSchema(ResolutionSchema),
strict: true
}
}
});

const resolution: Resolution = ResolutionSchema.parse(
JSON.parse(resolutionResponse.choices[0].message.content)
);

return { classification, resolution };
}

// Use with full type safety
const result = await processTicket("The app crashes on login");
console.log(result.classification.category); // "bug"
console.log(result.resolution.suggestedSolution); // string with IDE autocomplete

Key Takeaways

  • Zod schemas validate runtime data and infer TypeScript types automatically.
  • Use z.infer<typeof Schema> to extract types without duplication.
  • Convert Zod schemas to JSON Schema with zodToJsonSchema() for LLM APIs.
  • .parse() validates and throws errors; .safeParse() returns a result object.
  • Enums lock in valid values; discriminated unions handle conditional fields.
  • Nested schemas compose complex data structures elegantly.
  • Type safety extends through your entire application when using Zod + TypeScript.

Frequently Asked Questions

How do I validate with a custom error message?

Use .refine() for custom validation logic:

const schema = z.object({
age: z.number().int()
}).refine(data => data.age >= 0, {
message: "Age must be non-negative",
path: ["age"]
});

Can Zod handle discriminated unions based on field values?

Yes, with z.discriminatedUnion(). The discriminator field determines which schema is applied.

const schema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), content: z.string() }),
z.object({ type: z.literal("image"), url: z.string() })
]);

What if the LLM response includes extra fields?

By default, Zod ignores extra fields. To reject them, use .strict():

const schema = z.object({ name: z.string() }).strict();
// Throws error if extra fields are present

Does Zod add latency to LLM applications?

Negligibly. Validation is fast (microseconds). The latency benefit of structured output eliminates retries far outweighs validation cost.

Can I transform or coerce data during validation?

Yes, with .transform() or .coerce:

const schema = z.object({
count: z.coerce.number(), // Coerce string to number
date: z.string().transform(str => new Date(str))
});

Further Reading