Kneel Before Zod: Why This Type System + Convex is Your AI Cheat Code
If there's one thing I've learned from my exhausting love affair with Convex (yes, I'm a fanboy—and here's why), it's that good architecture doesn't come from inspiration. It comes from repeatedly making mistakes and finally surrendering to the truth that some patterns are better than others.
Today's problem is delightfully simple to describe and infuriatingly painful to live with: you maintain two parallel type systems for the same data.
Your AI framework uses Zod. Your database uses Convex validators. Both describe the same business object. Neither knows the other exists. Welcome to type drift, where you spend three hours debugging only to realize you forgot to update a field in one of the two places.
Let me show you how to stop this madness.
Why Your AI Framework Is Already Using Zod
It's not a conspiracy. Every major AI framework independently decided that Zod was the way. Superman would be so offended that AI developers so quickly bent the knee. This isn't because the Zod maintainers have exceptionally good marketing. It's because Zod solves a real problem: structured outputs from LLMs.
When you want Claude or GPT to return data in a specific shape, you need a schema. That schema needs to be:
- Serializable to JSON Schema (which the LLM can understand)
- Validatable at runtime (because LLMs hallucinate)
- Type-safe in TypeScript (because debugging untyped blobs is suffering)
Zod does all three. So LangChain uses it. Vercel's AI SDK uses it. Mastra uses it. Even the Claude API documentation shows examples using Zod-to-JSON-Schema conversion.
The moral: if you're doing AI development in 2025, you're using Zod whether you planned to or not.
The Obvious Problem (That Everyone Ignores)
So your AI framework speaks Zod fluently. Your database, if you're using Convex, speaks its own validator language:
// Your AI code
const FormSchemaZ = z.object({
name: z.string(),
fields: z.array(z.object({ type: z.enum(['text', 'checkbox']) }))
})
// Your Convex schema
const FormValidator = v.object({
name: v.string(),
fields: v.array(v.object({ type: v.union(v.literal('text'), v.literal('checkbox')) }))
})
Even though v. looks oddly familiar to Zod, they're fundamentally different—kind of like Superman and his Kryptonian nemesis Zod. Zod (the npm library) performs runtime validation that checks data after it enters your system, while Convex's v. is a schema definition system baked into Convex's type system and codegen, validating at the boundary when data hits your database or function arguments Convex. Both solve the same problem, but from opposite angles.

(And yes, we appreciate the irony that v. or "compound v" is literally the source of Homelander's powers in The Boys. Convex is giving us validation superpowers in a way that's delightfully on-the-nose.)
The result? Your AI frameworks expect Zod. Your database needs v.. When you add a new field type, you update both. When you refactor validation logic, you do it twice. When your teammate forgets one of the two places, you discover it at 2 AM during a production incident.
This is the type system equivalent of code duplication, except worse because the duplication is in your contracts, not your implementation.
The Zod-Convex Pattern
Here's where my Convex fanboy energy pays off. Convex actually supports this: you can define your schema once in Zod, then convert it to a Convex validator. You define once. You use everywhere.
The pattern is documented on Convex's stack, but let me show you how this works in practice.
Define Once, Use Everywhere
Create a centralized models file:
// models/formFields.ts
export const FormFieldZ = z.object({
type: z.enum(['text', 'checkbox', 'date']),
label: z.string(),
required: z.boolean(),
// Field-specific properties as optional
placeholder: z.string().nullable(), // for 'text' fields
defaultChecked: z.boolean().nullable(), // for 'checkbox' fields
minDate: z.string().nullable(), // for 'date' fields
maxDate: z.string().nullable(), // for 'date' fields
})
export const ParsedFormSchemaZ = z.object({
title: z.string(),
description: z.string().nullable(),
fields: z.array(FormFieldZ),
metadata: z.object({
pageCount: z.number(),
extractedAt: z.string().datetime(),
}),
})
// Convert to Convex validators (one-way trip)
export const FormFieldValidator = zodToConvex(FormFieldZ)
export const ParsedFormSchemaValidator = zodToConvex(ParsedFormSchemaZ)Now use in your Convex schema:
// convex/schema.ts
import { ParsedFormSchemaValidator } from '../models/formFields'
export default defineSchema({
extractedForms: defineTable({
organizationId: v.id('organizations'),
pdfStorageId: v.id('_storage'),
schema: ParsedFormSchemaValidator, // ← Same type as AI layer
status: v.union(v.literal('processing'), v.literal('complete')),
}).index('by_org', ['organizationId']),
})And in your AI action:
// convex/actions/parseFormPdf.ts
import { ChatOpenAI } from '@langchain/openai'
import { ParsedFormSchemaZ } from '../../models/formFields'
export const parseFormWithVision = internalAction({
args: {
pdfStorageId: v.id('_storage'),
organizationId: v.id('organizations'),
},
returns: v.null(),
handler: async (ctx, args) => {
const pdfUrl = await ctx.storage.getUrl(args.pdfStorageId)
const llm = new ChatOpenAI({ model: 'gpt-4o' })
const structuredLlm = llm.withStructuredOutput(ParsedFormSchemaZ)
const result = await structuredLlm.invoke([
{
role: 'user',
content: [
{ type: 'text', text: 'Extract form structure from this PDF.' },
{ type: 'image_url', image_url: { url: pdfUrl } },
],
},
])
// result is typed AND validated by Zod
// It automatically matches the DB schema because they're the same type
await ctx.runMutation(internal.db.extractedForms.create, {
organizationId: args.organizationId,
pdfStorageId: args.pdfStorageId,
schema: result, // ← Type-safe, validated, matches DB
status: 'complete',
})
},
})What We Actually Get
One Zod definition powering:
- Database validation (via
zodToConvex) - LLM structured outputs (native Zod support)
- TypeScript types (via
z.infer) - Runtime validation (Zod's parse/safeParse)
Change the schema once. Update it everywhere simultaneously. Your type system becomes a single source of truth instead of a house of mirrors.
The Gotchas (Yes, There Are Some)
Here's where I'm honest about the limitations:
Refinements like .email() and .url() only validate on the Zod side. When you convert to Convex, they become basic type validators. This is fine—use Zod refinements for AI output validation and trust Convex for storage boundary protection. Both layers catch most issues.
Complex union types are where zodToConvex gets cranky. If you're building deeply nested discriminated unions (union within union within union), the converter creates verbose, hard-to-read Convex validators. Instead of fighting it, define your complex unions in Convex using v. directly, then only convert the flat property types. This is a pragmatic compromise that keeps your schema readable.
Example: you're building an agent response handler with multiple result types:
// models/agentResponse.ts - Define flat property types in Zod
export const SuccessResultZ = z.object({
status: z.literal('success'),
data: z.any(), // Flattened payload
executedAt: z.string().datetime(),
})
export const ErrorResultZ = z.object({
status: z.literal('error'),
errorCode: z.string(),
message: z.string(),
retryable: z.boolean(),
})
// Export flat Zod types for conversion
export const SuccessResultValidator = zodToConvex(SuccessResultZ)
export const ErrorResultValidator = zodToConvex(ErrorResultZ)
// THIS WILL ERROR
export const AgentResponseZ = z.discriminatedUnion('status', [
SuccessResultZ,
ErrorResultZ,
])
// THIS WILL WORK
export const AgentResponseValidator = v.union(SuccessResultZ, ErrorResultZ)// convex/schema.ts - Define the complex union in Convex using v.
import { v } from 'convex/values'
import { SuccessResultValidator, ErrorResultValidator } from '../models/agentResponse'
export default defineSchema({
agentExecutions: defineTable({
agentId: v.id('agents'),
// The complex union lives here, not in zodToConvex
result: AgentResponseValidator,
timestamp: v.number(),
}).index('by_agent', ['agentId']),
})The key: Zod handles the AI output validation with precision. Convex's v. handles the schema definition. The flat property types convert cleanly, and the complex union logic lives where it belongs—in your database schema.
IDs like zid('tableName') validate table names on the server but not in browser-side .parse() calls. This is a limitation of Zod, not the pattern. Be aware when validating on the client.
Optional fields and undefined values silently behave differently than you'd expect. Zod's .optional() creates T | undefined, but Convex silently strips undefined values: {forms: undefined} becomes {}—the field is removed entirely, not stored as a value. This can be surprising when AI structured outputs explicitly return undefined for optional fields. Use .nullable() instead to get explicit null values, or transform undefined → null at storage boundaries:
typescript
// ⚠️ GOTCHA: undefined gets silently removed
const ResultZ = z.object({
forms: z.array(z.string()).optional(),
})
// When stored: { forms: undefined } silently becomes { } (field removed)
// ✅ BETTER: Use nullable for explicit semantics
const ResultZ = z.object({
forms: z.array(z.string()).nullable(), // string[] | null, not removed
// OR transform at boundary
forms: z.array(z.string()).optional().transform(v => v ?? null),
})TypeScript won't catch this—it's a runtime data behavior issue.
Convex limits (arrays max 8192 items, objects max 1024 entries) aren't validated by Zod. Add explicit .max() constraints to match Convex's boundaries.
How I keep things tight
The project structure I use is straightforward:
models/
├── formFields.ts // Zod schemas + zodToConvex exports
├── agentRequests.ts // Zod schemas + zodToConvex exports
└── README.md // Pattern documentation
convex/
├── schema.ts // Import validators from models/
├── actions/
│ └── parseForm.ts // Use Zod schemas for AI
└── db/
└── forms.ts // Type-safe queries
The models folder becomes your single source of truth. Everything else imports from there.
The Before and After
Before: You have Zod schemas for AI, Convex validators for the database, and TypeScript types inferred from both. Change one thing, update three places, miss one, deal with type drift at runtime.
After: You have one Zod definition. It powers your database validators through conversion, your AI frameworks natively, and your TypeScript types automatically. Change it once, and every layer updates instantly.
This isn't revolutionary. But it is how you stop maintaining your types in duplicate and start maintaining them once. Which, in a world where we're constantly adding new fields and refactoring schemas, is basically revolutionary.
The motorcycle accident was worth it for this lesson alone.
If you're building multi-agent systems or AI applications on Convex, this pattern will save you weeks of maintenance work. If you're not using Convex yet, well, I can't help you there—I'm a spiritually obligated fanboy.