SeanMiller
Blog

Super Simple Steps: Prompt Version Control & Structured Output (Part 2)

Sean Miller
#blog#series#instructions#tutorials#generative ai#structured output#zod#typescript

Super Simple Steps: Prompt Version Control & Structured Output (Part 2)

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:

  1. Prompts as Code — Version-controlled, templated, testable
  2. Structured Output — Zod schemas that guarantee valid JSON

The Problem with Plain Text

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.

Prompts as Code

Most tutorials hardcode prompts inside function bodies. This makes them:

The fix: Extract prompts into separate modules with explicit interfaces.

graph LR A[prompts.ts] -->|Template| B[index.ts] C[schema.ts] -->|Types| B B -->|Validated Output| D[(Database)] style A fill:#34A853,stroke:#333,stroke-width:2px,color:white style C fill:#FBBC04,stroke:#333,stroke-width:2px style B fill:#4285F4,stroke:#333,stroke-width:2px,color:white style D fill:#EA4335,stroke:#333,stroke-width:2px,color:white

Figure 1: Separate concerns. Prompts and schemas are first-class code artifacts.

The Prompt Module

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:

The Schema Module

Now 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.

Guaranteed JSON Output

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.

sequenceDiagram participant App participant Gemini participant Zod App->>Gemini: Prompt + JSON Schema Gemini->>Gemini: Generate constrained output Gemini->>App: Raw JSON string App->>Zod: Parse & validate Zod->>App: Typed object ✓ Note over App,Zod: Type safety from API to database

Figure 2: Schema validation happens twice—once by Gemini, once by Zod.

The Two-Phase Workflow Architecture

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.

graph TB subgraph "Step 1: Research" A[Prompt + Context] --> B[Gemini Pro + Thinking] B --> C[Free-form Analysis] end subgraph "Step 2: Extract" C --> D[Formatting Prompt] D --> E[Gemini Flash + Schema] E --> F[Validated JSON] end style B fill:#34A853,stroke:#333,stroke-width:2px,color:white style E fill:#4285F4,stroke:#333,stroke-width:2px,color:white style F fill:#FBBC04,stroke:#333,stroke-width:2px

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).

Production Code

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

Results in Production

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.

Key Takeaways

  1. Prompts are code. Extract them, version them, test them.
  2. Zod schemas guarantee type safety from API response to database.
  3. .describe() is documentation for the AI. Use it liberally.
  4. Two-step architecture separates reasoning from formatting.
  5. Low temperature + schema = deterministic output.

What’s Next

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.


Series Roadmap

  1. Generative AI — The basic primitive
  2. Structured Output (this post) — Prompt version control & Zod schemas
  3. Multimodal Input — Processing images with AI
  4. Embeddings & Semantic Search — Finding similar items
  5. Grounding & Search — Connecting AI to real-time data
  6. The Batch API — Processing thousands of items efficiently
  7. Building an AI Agent — Giving AI tools to solve problems
  8. Evaluating Success — Testing and measuring quality

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.

← Back to Blog