The Zod to Gemini Migration Blues: Why Your Schemas Are Breaking
Remember that beautiful TypeScript workflow you had? The one where you’d write a clean Zod schema, run it through zod-to-json-schema
, and boom—perfect structured outputs with Llama 3.3 on Together AI? Yeah, about that…
If you’re making the jump to Google’s Gemini 2.5 models and wondering why your perfectly good schemas are suddenly throwing errors, grab a coffee (or something stronger) and let me walk you through what I learned the hard way.
When Zod Just Worked
In the Together AI/Fireworks/Ollama/model-inferencing-as-service world, life was simple. You’d write something like this:
const userSchema = z.object({
email: z.string().email().min(5).max(255),
username: z.string().regex(/^[a-zA-Z0-9_-]{3,20}$/),
age: z.number().int().min(13).max(120),
tags: z.array(z.string()).min(1).max(10).optional(),
metadata: z.record(z.string()).optional(),
});
// Convert and send
const jsonSchema = zodToJsonSchema(userSchema);
const response = await together.chat.completions.create({
model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
response_format: { type: "json_object", schema: jsonSchema },
// ... rest of your config
});
And it just… worked. Every validation rule, every constraint, every nested object—Llama understood it all. DeepSeek V3? No problem. Qwen? Handled it beautifully. Your Zod schemas were your single source of truth, and life was good.
Why do these models handle structured output so well? They’ve been fine-tuned with similar standards and training data that emphasize following JSON schemas precisely. The open-source ecosystem has converged around respecting the full JSON Schema specification, making these models incredibly reliable for structured generation.
Welcome to Gemini: Where Half Your Schema Gets Ghosted
Now, let’s see what happens when you try to port that same schema to Gemini 2.5:
// Your beautiful Zod schema
const userSchema = z.object({
email: z.string().email().min(5).max(255), // ❌ .email(), .min(), .max() - all ignored
username: z.string().regex(/^[a-zA-Z0-9_-]{3,20}$/), // ❌ .regex() - not supported
age: z.number().int().min(13).max(120), // ❌ .min(), .max() - nope
tags: z.array(z.string()).min(1).max(10), // ✅ .min(), .max() on arrays actually work!
metadata: z.record(z.string()), // ❌ record types - forget about it
});
When you run this through zod-to-json-schema
and send it to Gemini, here’s what actually gets respected:
{
type: "object",
properties: {
email: { type: "string" }, // That's it. No email validation.
username: { type: "string" }, // No regex, no length limits
age: { type: "number" }, // Not even integer validation
tags: {
type: "array",
items: { type: "string" },
minItems: 1, // Hey, at least this works!
maxItems: 10
}
// metadata is just... gone. Record types? What are those?
}
}
The Lossy Conversion Problem
Here’s the kicker: when you use zod-to-json-schema
, it dutifully converts ALL your Zod constraints into JSON Schema format. But Gemini? It cherry-picks what it wants to support and silently ignores the rest. No errors, no warnings—your carefully crafted validations just vanish into the void.
This is what I call the “lossy conversion problem.” You think you’re getting type safety and validation, but you’re actually getting… suggestions. Gentle suggestions that Gemini might or might not follow.
Why This Hits Different
If you’re coming from the open-source LLM world, this feels like betrayal. With Fireworks or Together AI hosting your Llama models, your infrastructure respected your schemas. VLLM? Same story. Even self-hosted Ollama setups handle complex JSON schemas better than Google’s flagship model.
The irony? Gemini 2.5 is arguably one of the most capable models out there. Its reasoning is top-notch, context window is massive, and it handles complex tasks beautifully. But structured output? That’s where it decides to channel its inner minimalist.
Now, before you think I’m holding back some secret knowledge—this isn’t hidden information. It’s all there in the Vertex AI documentation. The problem? Google writes docs like you’ve been riding shotgun with them since the beginning, following every API change and feature rollout in perfect chronological order. But let’s be real: nobody does that. We hop between providers, try new models, and piece together solutions from whatever works. Google’s docs assume a level of platform intimacy that most of us just don’t have.
So, What Actually Works
After much trial, error, and mildly concerning amounts of coffee, here’s what I’ve learned about making Gemini play nice with structured output:
1. Forget Zod-to-JSON, Think Gemini-First
Instead of:
const schema = z.object({
email: z.string().email(),
status: z.enum(["active", "pending", "suspended"]),
});
Write your schemas directly for Gemini:
const responseSchema = {
type: "object",
properties: {
email: {
type: "string",
description: "User's email address in valid email format", // Your validation is now a prayer
},
status: {
type: "string",
enum: ["active", "pending", "suspended"], // Enums actually work!
},
},
required: ["email", "status"],
};
2. Embrace Description-Driven Development
Since you can’t use regex, min/max on strings, or most validation rules, your descriptions become your new best friend:
// Instead of: z.string().regex(/^[A-Z]{2}-\d{4}$/)
{
type: "string",
description: "Product code in format XX-9999 where X is uppercase letter and 9 is digit"
}
Will Gemini always follow this? Usually. Always? Well… that’s between you and the model.
3. The Flattening Strategy
Complex nested schemas make Gemini struggle. Instead of:
{
user: {
profile: {
settings: {
notifications: {
email: boolean,
sms: boolean
}
}
}
}
}
Consider flattening:
{
userNotificationEmail: boolean,
userNotificationSms: boolean
}
Less elegant? Sure. More likely to work? Absolutely.
4. Use What Actually Works
Here’s your survival toolkit of Gemini-supported features:
- Enums: Your new best friend for constraining values
- Array min/max: Actually respected!
- Property ordering: Gemini-specific but useful
- Clear descriptions: Your primary validation mechanism
- Required fields: These work reliably
5. Build Your Own Validation Layer
The hard truth? You’ll need to validate on your end:
// Get response from Gemini
const response = await ai.models.generateContent({
model: "gemini-2.0-flash",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: simplifiedSchema,
},
});
// Parse and validate with your original Zod schema
const parsed = JSON.parse(response.text);
const validated = userSchema.safeParse(parsed);
if (!validated.success) {
// Handle validation errors
// Maybe retry with clearer instructions?
}
But Wait, It’s Not All Bad
Look, I’ve been pretty harsh on Gemini’s structured output support, but here’s the thing: once you accept its limitations and work within them, it’s actually quite reliable. The model is smart enough to follow well-written descriptions, and the simplified schema format means less chance of confusing the model with overly complex constraints.
Plus, Google is actively working on this. The fact that they added propertyOrdering
shows they’re listening to developer feedback . Who knows? By the time you read this, they might support three more fields! (A developer can dream, right?)
Your Migration Checklist
Ready to make the jump? Here’s your survival guide:
- Audit your Zod schemas: List every constraint you’re using
- Identify what won’t work: Regex, string lengths, number ranges, record types
- Rewrite for Gemini: Focus on supported fields only
- Enhance descriptions: Move validation rules to descriptions
- Add post-processing validation: Use your original Zod schemas to validate responses
- Test extensively: What worked in dev might surprise you in production
- Have a fallback plan: Sometimes you need to retry with clearer instructions
The Bottom Line
Moving from the Zod + Together AI/Fireworks ecosystem to Gemini feels like switching from a Swiss Army knife to a really good butter knife. Sure, the butter knife is beautifully crafted, does its one job exceptionally well, and never lets you down. But you’re going to miss having a screwdriver, scissors, and that tiny toothpick when you need them.
The good news? Gemini 2.5’s core capabilities often make up for the structured output limitations. And hey, at least you’re not alone in this. We’re all out here writing descriptions like “Please ensure the date is in YYYY-MM-DD format, I’m begging you” and hoping for the best.
Welcome to the Gemini era. May your schemas be simple and your descriptions be heard.