Structured output solves an important model problem: instead of asking Gemini for “JSON-like” prose, you provide a schema and receive syntactically structured data.
Then the software-engineering problem begins.
Once a tool, UI, downstream agent, database, or audit process depends on that JSON, the model output is an API. Renaming a field can break consumers. Adding an enum value can send an older UI into an impossible state. Valid JSON can still describe an invalid business decision.
Suppose a travel assistant initially returns:
type TravelDecisionV1 = {
schemaVersion: "1";
action: "book" | "ask" | "wait";
reason: string;
};
Later, the product needs suppression and evidence references. Quietly changing the original shape is risky. Introduce a new discriminated version:
type TravelDecisionV2 = {
schemaVersion: "2";
decision: "act" | "ask" | "wait" | "suppress";
reasonCode: string;
evidenceIds: string[];
confidence?: {
value: number;
source: "classifier" | "model" | "rule";
};
};
type TravelDecision = TravelDecisionV1 | TravelDecisionV2;
The version belongs in the persisted value, not only in the deployment configuration. Stored outputs can outlive the model call that created them.
Gemini supports structured output through a provided JSON Schema. Its JavaScript SDK can use a JSON Schema together with Zod validation. The abbreviated example below keeps the model identifier in configuration:
import { GoogleGenAI } from "@google/genai";
import * as z from "zod";
const decisionSchema = z.object({
schemaVersion: z.literal("2"),
decision: z.enum(["act", "ask", "wait", "suppress"]),
reasonCode: z.string().min(1),
evidenceIds: z.array(z.string()),
});
const jsonSchema = z.toJSONSchema(decisionSchema);
const client = new GoogleGenAI({});
const response = await client.interactions.create({
model: process.env.GEMINI_MODEL!,
input: "Evaluate the synthetic travel-change fixture.",
response_format: {
type: "text",
mime_type: "application/json",
schema: jsonSchema,
},
});
const decision = decisionSchema.parse(
JSON.parse(response.output_text),
);
The Gemini structured-output documentation explicitly recommends application validation. Gemini implements a subset of JSON Schema, and very large or deeply nested schemas may be rejected.
For a public contract, make the schema as closed and bounded as the supported Gemini subset allows. Limit array sizes, constrain string formats where meaningful, and reject unexpected properties at the application boundary. A permissive schema followed by a strict consumer only moves the failure downstream.
“The response was valid” can mean several different things:
| Check | Example |
|---|---|
| Syntax | The response parses as JSON |
| Shape | Required properties and enum values match the schema |
| Semantics | Referenced evidence IDs exist |
| Policy | act is permitted for this user and current state |
Structured generation helps primarily with the first two. Your application still owns the last two.
function validateSemantics(d: TravelDecisionV2, knownIds: Set<string>) {
if (d.decision === "act" && d.evidenceIds.length === 0) {
throw new Error("ACT_REQUIRES_EVIDENCE");
}
if (d.evidenceIds.some((id) => !knownIds.has(id))) {
throw new Error("UNKNOWN_EVIDENCE_REFERENCE");
}
}
Do not retry every validation failure with “please fix your JSON.” A syntax failure, an unsupported enum, and a policy violation require different handling. A policy violation should normally be blocked, not reprompted until the model finds a permissive answer.
Changes are usually safer when they are additive:
Enum additions deserve special care. TypeScript consumers often use exhaustive switches:
function renderDecision(d: TravelDecisionV2) {
switch (d.decision) {
case "act": return "Executing";
case "ask": return "Needs information";
case "wait": return "Monitoring";
case "suppress": return "No action";
default: return "Unsupported decision";
}
}
That fallback protects the UI, but it should also emit a bounded compatibility error.
For each supported model configuration, test:
Do not assert exact prose in reasonCode explanations. Assert the stable schema, references, and business constraints.
Persist a small generation envelope beside the output:
type GenerationEnvelope = {
schemaVersion: "2";
schemaHash: string;
modelId: string;
generatedAt: string;
value: TravelDecisionV2;
};
schemaHash is not a replacement for the human-readable version. It detects drift when two deployments both claim to produce V2 but were built from different schema documents. The model identifier supports diagnosis; it should not become the only migration key.
Structured output makes agent integration substantially cleaner, but it does not remove API design. Version the value, validate it at runtime, distinguish semantic and policy failures, and preserve migration tests.
If another component can break when the model's JSON changes, you already have an API—even if there is no HTTP endpoint.