When you wire up Gemini API with responseMimeType: "application/json" and a responseSchema, are you using JSON.parse(response.text) as MyType on the TypeScript side and calling it a day? I did, for a while, before noticing in production that LLM output mostly conforms to the schema but occasionally drifts — wrong field order, an unexpected null, an integer arriving as a string. Code that relies on as happily lets all of those slip through.
This article walks through a pattern for validating Gemini responses at runtime with Zod. By treating the Zod schema as the single source of truth, both the JSON Schema sent to Gemini and the TypeScript types in your code can be derived from one definition.
Why as casting falls short
TypeScript type casts are a compile-time-only check. The moment you write as MyType, the compiler trusts you. It never inspects the actual value.
// ❌ Unsafe pattern
const result = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: prompt,
config: { responseMimeType: "application/json", responseSchema: schema },
});
const data = JSON.parse(result.text) as Article;
console.log(data.title.toUpperCase()); // throws if data.title is undefinedGemini's structured output is accurate — it returns the requested shape well over 90% of the time. But the remaining few percent matter. From a user's perspective, an app that crashes occasionally is an unstable app. In production you need to detect those drifts rather than swallow them.
Zod is a widely adopted runtime validation library in the TypeScript ecosystem. You can derive types from a schema definition, and parse() either returns a typed value or throws a detailed error. It pairs well with Gemini because zod-to-json-schema can convert a Zod schema into the JSON Schema format Gemini expects.
The basic flow: passing a Zod schema to Gemini
Install the dependencies first.
npm install @google/genai zod zod-to-json-schemaThen define a Zod schema for the response. We'll use review extraction as the running example.
// schema.ts
import { z } from "zod";
export const ReviewSchema = z.object({
productName: z.string().min(1).describe("Product the review is about"),
rating: z.number().int().min(1).max(5).describe("1-5 satisfaction score"),
sentiment: z.enum(["positive", "neutral", "negative"]),
pros: z.array(z.string()).max(5),
cons: z.array(z.string()).max(5),
});
export type Review = z.infer<typeof ReviewSchema>;z.infer extracts the TypeScript type, so everything else in your codebase derives from this single file. Change the schema and the types follow automatically.
When passing this to Gemini, convert it with zod-to-json-schema.
// gemini.ts
import { GoogleGenAI } from "@google/genai";
import { zodToJsonSchema } from "zod-to-json-schema";
import { ReviewSchema, type Review } from "./schema";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
export async function extractReview(rawText: string): Promise<Review> {
const jsonSchema = zodToJsonSchema(ReviewSchema, {
target: "openApi3", // Gemini accepts OpenAPI 3-compatible schemas
});
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `Extract structured fields from this review:\n\n${rawText}`,
config: {
responseMimeType: "application/json",
responseSchema: jsonSchema,
},
});
return ReviewSchema.parse(JSON.parse(response.text ?? "{}"));
}Two things matter here. First, responseSchema tells Gemini what shape to return. Second, the response is re-validated with ReviewSchema.parse() on your side. This double check is the heart of the Zod integration. Gemini's structured output is strong, but you don't want to depend on it alone — validating again locally lets you catch unexpected responses safely.
Retry design when validation fails
When parse() throws, dropping the request is rarely the right move. Feeding the validation error back to Gemini and retrying is a more practical pattern.
// retry.ts
import { z } from "zod";
export async function extractWithRetry<T>(
schema: z.ZodSchema<T>,
generate: (errorContext?: string) => Promise<string>,
maxAttempts = 3,
): Promise<T> {
let lastError: string | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const text = await generate(lastError);
try {
return schema.parse(JSON.parse(text));
} catch (e) {
if (e instanceof z.ZodError) {
lastError = `Previous response failed validation: ${e.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}. Please follow the schema exactly.`;
if (attempt === maxAttempts) throw e;
} else {
throw e;
}
}
}
throw new Error("unreachable");
}With this helper, the rare drift becomes recoverable: include the error message in the next prompt and Gemini will usually fix the shape on the second attempt. In practice three attempts is a generous ceiling.
Three pitfalls worth knowing
A few things tripped me up while building this pattern, and they're worth flagging.
1. z.union and z.discriminatedUnion
Gemini supports oneOf only partially. A plain z.union produces JSON Schema with oneOf, which sometimes confuses the model. z.discriminatedUnion is safer when it works; if it still misbehaves, fall back to expressing the variant via z.enum and a regular object.
2. Don't skimp on .describe()
Zod's .describe() text becomes the JSON Schema description, which is fed to Gemini. This directly affects accuracy. "rating: 1-5 score" is fine. "rating: user satisfaction as an integer 1-5, where 1 is very poor and 5 is excellent" yields noticeably more consistent output.
3. Avoid z.date()
JSON has no native date type, so Gemini returns strings. Define the schema as z.string().datetime() and add .transform((v) => new Date(v)) if you need a Date object downstream. Passing z.date() to zodToJsonSchema will produce a schema that Gemini cannot satisfy.
Sharing the schema between client and server
If you're on a fullstack framework like Next.js, place the Zod schema in something like lib/schemas.ts and use it from both the server-side Gemini call and the client-side form. With one source of truth you avoid the classic bug where someone adds a field on one side but forgets the other.
A practical setup: the API route receives Gemini's response, validates with Zod, and returns JSON to the client. The client revalidates with the same schema before storing the result in React state. Since the type comes from z.infer, frontend and backend stay perfectly aligned.
For going deeper on response patterns, Production response schema validation for Gemini API and Designing type-safe AI applications in TypeScript cover related ground. If you'd rather implement the same pattern in Python, Gemini API × Pydantic for type-safe structured output is the companion piece.
Testing the integration without burning quota
One side benefit of having a Zod schema is that your tests can validate fixture data without ever hitting Gemini. Write a JSON file that represents a typical (or edge-case) response, load it in a test, and run ReviewSchema.parse() against it. Any time you adjust the schema, the test suite tells you whether your fixtures are still valid — without spending a single API call.
// schema.test.ts
import { describe, it, expect } from "vitest";
import { ReviewSchema } from "./schema";
describe("ReviewSchema", () => {
it("accepts a well-formed response", () => {
const fixture = {
productName: "Wireless Earbuds X",
rating: 4,
sentiment: "positive",
pros: ["good battery", "comfortable fit"],
cons: ["app is buggy"],
};
expect(() => ReviewSchema.parse(fixture)).not.toThrow();
});
it("rejects an invalid sentiment value", () => {
const fixture = { productName: "X", rating: 3, sentiment: "okay", pros: [], cons: [] };
expect(() => ReviewSchema.parse(fixture)).toThrow();
});
});The combination of a strict schema and a few representative fixtures catches most regressions before they reach the LLM call.
When to reach for safeParse instead of parse
By default parse() throws on invalid data, which is fine inside a service that has retry wrapping. In code paths where you want to inspect the result without the cost of try/catch, safeParse() returns a discriminated union you can pattern match on:
const result = ReviewSchema.safeParse(JSON.parse(text));
if (!result.success) {
logger.warn({ issues: result.error.issues }, "validation failed");
return fallbackReview;
}
return result.data;This is especially handy in serverless handlers where you'd rather degrade gracefully than throw a 500 to the user. Choose based on the surrounding context: parse for hot paths protected by a retry loop, safeParse when the caller wants to branch on success.
Start with a small schema
The nice thing about adopting Zod here is that you can start tiny and grow. Begin with a two- or three-field object, then add nesting, arrays, and enums as you get comfortable. Pick one Gemini structured-output endpoint in your current app and wrap a single parse() around it today. That alone tends to surface drifts you never noticed before.