
Disclaimer: This series documents patterns and code from building Thrifty Trip, my personal side project. All code examples, architectural decisions, and opinions are my own and are not related to my employer. Code is provided for educational purposes under the Apache 2.0 License.
In Part 1, we sent text to the Gemini API and got text back: Nike | 90s | XL. Human-readable, but awkward to parse. What happens when the model returns Nike - 90s - XL instead? Or adds extra whitespace?
This post introduces two patterns that eliminate parsing headaches forever:
Text output is non-deterministic. Even with the same prompt, models return slightly different formats:
Nike | Shoes | XL // Expected
Nike - Shoes - XL // Dash instead of pipe
Brand: Nike, Category: Shoes, Size: XL // Completely different format
You end up writing brittle parsers. Then the model “learns” a new format, and your code breaks in production.
Most tutorials hardcode prompts inside function bodies. This makes them:
The fix: Extract prompts into separate modules with explicit interfaces.
Figure 1: Separate concerns. Prompts and schemas are first-class code artifacts.
Here’s how we define prompts in Thrifty Trip’s prompts.ts:
/*
* Copyright 2025 Thrifty Trip LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Simple template interpolation.
* Replaces {{ variableName }} with values from the variables object.
*/
export function interpolate(
template: string,
variables: Record<string, string>
): string {
return template.replace(
/\{\{\s*(\w+)\s*\}\}/g,
(_, key) => variables[key] ?? `{{ ${key} }}`
);
}
export const RESEARCH_PROMPT = `You are an expert reseller evaluating a listing.
ITEM DETAILS:
{{ itemJson }}
HISTORICAL PERFORMANCE:
{{ historicalDataJson }}
Analyze pricing, demand, and provide actionable recommendations.`;
Why this works:
interpolate() with mock dataNow the real magic: Zod schemas that become Gemini’s data contract. Zod is a library for validating and parsing data. It’s how we ensure Gemini’s response is valid and matches our app’s expectations.
Here’s how to define a Zod schema for the evaluation:
/*
* Copyright 2025 Thrifty Trip LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from "npm:zod"; // https://www.npmjs.com/package/zod
export const EvaluationSchema = z.object({
overall_score: z
.number()
.describe("the overall listing score from evaluation"),
strengths: z
.array(z.string())
.describe("the specific qualities of the item that are strong"),
weaknesses: z
.array(z.string())
.describe("the specific weaknesses of the item"),
action_items: z
.array(
z.object({
priority: z
.enum(["high", "medium", "low"])
.describe("the priority of the action item"),
action: z.string().describe("the action item"),
})
)
.describe("the action items to improve the listing"),
sell_probability: z
.object({
"30_days": z
.number()
.describe("the probability of selling the item in 30 days"),
"60_days": z
.number()
.describe("the probability of selling the item in 60 days"),
})
.describe("the probability of selling the item in 30 and 60 days"),
detailed_analysis: z
.string()
.describe("the detailed analysis of the listing"),
});
// TypeScript type is automatically inferred!
export type EvaluationType = z.infer<typeof EvaluationSchema>;
Key insight: The .describe() is for both documentation and for Gemini to understand what each field means.
Here’s how to force Gemini to return valid JSON matching your schema using the zodToJsonSchema library:
/*
* Copyright 2025 Thrifty Trip LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GoogleGenAI } from "npm:@google/genai";
import { zodToJsonSchema } from "npm:zod-to-json-schema";
import { EvaluationSchema } from "./schema.ts";
const ai = new GoogleGenAI({ apiKey: Deno.env.get("GEMINI_API_KEY") });
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
temperature: 0.1, // Low temp = more deterministic
responseMimeType: "application/json",
responseJsonSchema: zodToJsonSchema(EvaluationSchema),
},
});
// Parse and validate in one step
const evaluation = EvaluationSchema.parse(JSON.parse(response.text));
The response is guaranteed to match your schema. If the model can’t produce valid output, the API returns an error—no silent failures.
Figure 2: Schema validation happens twice—once by Gemini, once by Zod.
Until gemini-3-pro-preview, it was not possible to combine structured output with tools, such as grounding with google search and url context. With the introduction of Gemini 3.0, it is technically feasible to do so via API, though there are still caveats.
Not only from a separation of concerns perspective, but also from a performance perspective, it continues to be a best practice to adopt a workflow-based approach where complex analysis and research comes first, and then formatting comes second.
Figure 3: Use expensive models for reasoning, cheap models for formatting.
Step 1 uses a powerful model with thinking enabled—let it explore, search, and reason without structural constraints.
Step 2 uses a fast model with strict schema enforcement—it just extracts and formats.
This costs less than running the powerful model with schema constraints (which limits its reasoning ability).
Here’s the complete extraction step from our evaluate-listing function:
/*
* Copyright 2025 Thrifty Trip LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { interpolate, FORMATTING_PROMPT } from "./prompts.ts";
import { EvaluationSchema } from "./schema.ts";
import { zodToJsonSchema } from "npm:zod-to-json-schema"; // https://www.npmjs.com/package/zod-to-json-schema
// Step 2: Format the raw analysis into structured JSON
const formattingPrompt = interpolate(FORMATTING_PROMPT, {
rawAnalysisText, // Output from Step 1
});
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: formattingPrompt }] }],
config: {
temperature: 0.1,
responseMimeType: "application/json",
responseJsonSchema: zodToJsonSchema(EvaluationSchema),
},
});
// Validate and get full TypeScript types
const evaluation = EvaluationSchema.parse(JSON.parse(response.text));
// Now you have: evaluation.overall_score, evaluation.strengths, etc.
// All fully typed, all guaranteed to exist
In Thrifty Trip, this pattern powers:
Since adopting Zod schemas, we’ve had zero parsing failures in production. The schema is the contract, both for the AI and for our TypeScript code.
.describe() is documentation for the AI. Use it liberally.We’re now getting structured text data reliably. But what about images? In the next post, “Super Simple Steps: Multimodal Magic,” I’ll show you how to send images to Gemini and extract typed data from photos—perfect for analyzing product condition, reading labels, and more.
This series documents real patterns from building Thrifty Trip, a production inventory management app for fashion resellers. Code samples are available under the Apache 2.0 License.