Zod for TypeScript Runtime Validation
Zod brings runtime validation to TypeScript AI applications, ensuring LLM outputs match your types at runtime while maintaining compile-time type safety.
Zod is a TypeScript-first schema validation library. In AI development, it bridges the gap between TypeScript's compile-time types and runtime validation, ensuring LLM outputs are not just typed but actually validated.
The key insight: TypeScript types disappear at runtime. Zod schemas validate at runtime AND generate TypeScript types, giving you both compile-time and runtime safety.
The TypeScript Problem Zod Solves
TypeScript types are compile-time only:
interface Person {
name: string;
email: string;
age: number;
}
// TypeScript thinks this is safe
const response = await fetch('/api/llm');
const person: Person = await response.json();
// But at runtime, there's NO validation!
// What if the LLM returned {"name": 123, "email": null}?
// TypeScript won't catch this — it only checks at compile time.
Zod provides runtime validation:
import { z } from 'zod';
const PersonSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive()
});
// Infer TypeScript type from schema
type Person = z.infer<typeof PersonSchema>;
const response = await fetch('/api/llm');
const data = await response.json();
// Validate at runtime
const person = PersonSchema.parse(data);
// Throws error if validation fails!
// person is now type-safe AND validated
import { z } from 'zod';
import OpenAI from 'openai';
import { zodToJsonSchema } from 'zod-to-json-schema';
const openai = new OpenAI();
// Define schema with Zod
const SkillSchema = z.object({
name: z.string(),
years_experience: z.number().int().min(0),
proficiency: z.enum(['beginner', 'intermediate', 'expert'])
});
const ResumeSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
skills: z.array(SkillSchema).min(1).max(20),
summary: z.string().optional()
});
// Infer TypeScript types
type Skill = z.infer<typeof SkillSchema>;
type Resume = z.infer<typeof ResumeSchema>;
// Resume is now: { name: string; email: string; skills: Skill[]; summary?: string }
// Convert Zod schema to JSON Schema for OpenAI
const jsonSchema = zodToJsonSchema(ResumeSchema, 'resume');
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'user', content: 'Extract resume: John Doe, john@example.com, 5 years Python...' }
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'resume',
schema: jsonSchema,
strict: true
}
}
});
// Parse and validate the response
const resume = ResumeSchema.parse(
JSON.parse(response.choices[0].message.content ?? '{}')
);
// Now resume is both type-safe and runtime-validated
console.log(resume.name); // TypeScript knows this is string
console.log(resume.email); // TypeScript knows this is string (validated email)
for (const skill of resume.skills) { // TypeScript knows this is Skill[]
console.log(`${skill.name}: ${skill.proficiency}`); // Full autocomplete
} Zod Features for AI
1. Validation Methods
const schema = z.string().email();
// parse() - throws on error
try {
const email = schema.parse(data);
} catch (error) {
console.error(error);
}
// safeParse() - returns result object
const result = schema.safeParse(data);
if (result.success) {
console.log(result.data); // validated data
} else {
console.error(result.error); // validation errors
}
2. Schema Composition
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string()
});
const PersonSchema = z.object({
name: z.string(),
address: AddressSchema // Nested validation
});
const CompanySchema = z.object({
name: z.string(),
employees: z.array(PersonSchema) // Array of validated objects
});
3. Refinements (Custom Validation)
const PasswordSchema = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val),
{ message: "Must contain uppercase letter" }
)
.refine(
(val) => /[0-9]/.test(val),
{ message: "Must contain number" }
);
const AgeSchema = z.number()
.int()
.refine(
(val) => val >= 18 && val <= 120,
{ message: "Age must be between 18 and 120" }
);
4. Transformations
const DateSchema = z.string().transform((str) => new Date(str));
const UppercaseSchema = z.string().transform((s) => s.toUpperCase());
const TrimmedSchema = z.string().trim(); // Built-in transform
const result = DateSchema.parse("2025-12-20");
// result is Date object, not string
5. Discriminated Unions
const ToolCallSchema = z.object({
type: z.literal('function'),
function_name: z.string()
});
const TextResponseSchema = z.object({
type: z.literal('text'),
content: z.string()
});
const ResponseSchema = z.discriminatedUnion('type', [
ToolCallSchema,
TextResponseSchema
]);
// Zod uses 'type' field to determine which schema to use
const response = ResponseSchema.parse(data);
if (response.type === 'function') {
console.log(response.function_name); // TypeScript knows this exists
} else {
console.log(response.content); // TypeScript knows this exists
}
Zod with Astro Content Collections
Astro (the framework powering this platform's frontend) uses Zod for content validation:
import { z, defineCollection } from 'astro:content';
// Define schema for content files
const conceptCollection = defineCollection({
type: 'data',
schema: z.object({
id: z.string(),
name: z.string(),
category: z.enum(['fundamentals', 'patterns', 'protocols', 'type_systems']),
summary: z.string(),
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
estimated_time: z.string(),
tags: z.array(z.string()),
version_added: z.string(),
last_updated: z.string()
})
});
export const collections = {
concepts: conceptCollection
};
// Astro validates all content at build time
// Invalid content = build fails (catches errors before production) Zod vs TypeScript Interfaces
| Aspect | TypeScript Interfaces | Zod |
|---|---|---|
| Compile-time types | ✓ | ✓ (via z.infer) |
| Runtime validation | ✗ | ✓ |
| JSON Schema generation | ✗ | ✓ (via zod-to-json-schema) |
| Error messages | Compile errors only | Detailed runtime errors |
| Transformations | ✗ | ✓ |
| Custom validation | ✗ | ✓ (via refine) |
| Dynamic schemas | ✗ | ✓ |
When to Use Zod
| Use Zod When | Consider Alternatives When |
|---|---|
| Validating external data (APIs, LLMs) | Data is internal and type-safe |
| Building TypeScript AI apps | Using Python (use Pydantic) |
| You need runtime type safety | Compile-time checks are sufficient |
| Working with OpenAI/Anthropic APIs | Simple key-value extraction |
| Content validation (Astro, tRPC) | Performance is absolutely critical |
Common Patterns
API Response Validation
import { z } from 'zod';
const ApiResponseSchema = z.object({
success: z.boolean(),
data: z.unknown(), // Validated later
error: z.string().optional()
});
const response = await fetch('/api/llm');
const json = await response.json();
const result = ApiResponseSchema.parse(json);
if (result.success) {
const data = MyDataSchema.parse(result.data);
}
Environment Variable Validation
const EnvSchema = z.object({
OPENAI_API_KEY: z.string().min(1),
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform(Number).pipe(z.number().int().positive())
});
// Validate env vars at startup
const env = EnvSchema.parse(process.env);
// App won't start if env vars are invalid
Form Data Validation
const FormSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{ message: "Passwords don't match", path: ['confirmPassword'] }
);
const result = FormSchema.safeParse(formData);
if (!result.success) {
console.error(result.error.flatten());
}
Key Takeaways
- Zod provides runtime validation for TypeScript (types disappear at runtime)
- z.infer<> extracts TypeScript types from schemas
- zodToJsonSchema converts Zod to JSON Schema for LLM APIs
- Use parse() for errors or safeParse() for result objects
- Astro Content Collections use Zod for build-time validation
- Zod schemas are composable, transformable, and dynamic
In This Platform
This platform will use Zod extensively in the Astro frontend. Content Collections validate all JSON content (dimensions, concepts, modules) at build time using Zod schemas. This ensures only valid content reaches production, catching errors early.
- frontend/src/content/config.ts (future)
- schema/survey.schema.json
- schema/concept.schema.json
// Future: Astro Content Collections config
import { z, defineCollection } from 'astro:content';
const SourceReferenceSchema = z.object({
id: z.string(),
claim: z.string(),
quote: z.string().optional(),
page: z.string().optional()
});
const QuestionSchema = z.object({
id: z.string(),
text: z.string(),
type: z.enum(['single_choice', 'multi_select', 'likert']),
options: z.array(z.object({
text: z.string(),
score: z.number().int().min(0),
sources: z.array(SourceReferenceSchema).default([])
})).min(1),
max_score: z.number().int(),
sources: z.array(SourceReferenceSchema).default([])
});
const dimensionCollection = defineCollection({
type: 'data',
schema: z.object({
dimension_id: z.string(),
questions: z.array(QuestionSchema)
})
});
export const collections = { dimensions: dimensionCollection };
// Build fails if any dimension file doesn't match schema