●NANOLITE — Nano Banana 2 Lite is now available as the fastest, most cost-efficient Gemini Image model●OMNIFLASH — Gemini Omni Flash comes to the API in public preview for building custom, dynamic video workflows●AGENTS — Managed Agents arrives in the Gemini API, running autonomous agents in secure Google-hosted Linux sandboxes●FILESEARCH — File Search now supports multimodal search via gemini-embedding-2, with media_id for visual citations●WEBHOOKS — Event-driven Webhooks replace polling for the Batch API and long-running operations●DEPRECATE — Older image generation models are deprecated and shut down on Aug 17, so plan your migration●NANOLITE — Nano Banana 2 Lite is now available as the fastest, most cost-efficient Gemini Image model●OMNIFLASH — Gemini Omni Flash comes to the API in public preview for building custom, dynamic video workflows●AGENTS — Managed Agents arrives in the Gemini API, running autonomous agents in secure Google-hosted Linux sandboxes●FILESEARCH — File Search now supports multimodal search via gemini-embedding-2, with media_id for visual citations●WEBHOOKS — Event-driven Webhooks replace polling for the Batch API and long-running operations●DEPRECATE — Older image generation models are deprecated and shut down on Aug 17, so plan your migration
When an Optional Field Comes Back Three Ways: Null, Empty String, and Missing Key in Gemini Structured Output
Optional fields in Gemini structured output drift between null, empty string, and a missing key, and downstream code breaks in three different ways. Here is how I collapse all three into one shape using nullable in responseSchema and a post-output normalization gate, with numbers from a nightly batch.
Last week the nightly batch I run as an indie developer had quietly died before dawn. It is a small pipeline: classify App Store reviews with Gemini structured output, then write the summary and an improvement tag into Firestore. The stack trace pointed at a single line that upper-cased suggested_tag, with Cannot read properties of null.
A job that had run fine the day before fell over because the model's output wobbled slightly. The cause was that one optional field was arriving in three different shapes: null, an empty string, and sometimes with the key missing entirely. I was already passing a responseSchema, so why was this happening? Fixing it took work at both the entry and the exit of the pipeline. Here is how it settled down.
What "three shapes" actually looks like
With the same schema and the same prompt, an optional field does not settle on one representation. Here are the three forms suggested_tag (an optional improvement tag) came back as, pulled from real logs.
Case
Actual output
Type in JavaScript
Naive code behavior
Value present
"suggested_tag": "dark-mode"
string
Works
Null
"suggested_tag": null
null (object)
Throws on .toUpperCase()
Empty string
"suggested_tag": ""
string (length 0)
Injects an empty tag
Missing key
key absent
undefined
Slips past later branches
The tricky part is that all three mean the same thing — "there is no tag" — yet they break downstream code in different ways. Null throws, the empty string looks harmless while it seeds meaningless data, and the missing key sails through a conditional and resurfaces later as a separate, harder-to-locate bug. That asymmetry in how they fail is exactly what made the root cause hard to isolate.
Why an optional field becomes three shapes
A responseSchema constrains the type and structure of a field, but it does not decide, on its own, how "no value" should be represented. That was the assumption I had wrong.
First, a field you leave out of required can be dropped entirely at the model's discretion. Second, even inside required, if you do not declare nullable, the model fills "no value" with an empty string here and null there, following whatever it happened to generate. Third, pinning field order with propertyOrdering stabilizes order, not value representation — those are two separate axes. Order stability is a different problem, which I covered in stabilizing field order with propertyOrdering; this drift lives one layer deeper.
So a schema alone cannot shrink three shapes into one. You narrow the drift at the entry point (schema design) and fold what remains at the exit (post-output normalization). It takes both.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Trace why optional fields drift across null, empty string, and missing key by looking at responseSchema behavior
✦Use nullable and required together to narrow the drift at the entry point
✦A normalization gate that folds the three shapes into one, with the missing-value rate from 3,200 reviews
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Tightening the entry point: nullable and required in responseSchema
Start at the entry. Instead of treating an optional field as "may be omitted," decide firmly that it "always exists, and when there is no value, it is null." Putting the field in required and declaring nullable: true sharply reduced both the missing-key and the empty-string drift.
import { GoogleGenAI, Type } from "@google/genai";const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });const reviewSchema = { type: Type.OBJECT, properties: { sentiment: { type: Type.STRING, enum: ["positive", "neutral", "negative"] }, summary: { type: Type.STRING }, // Keep optional fields in required, but use nullable so "no value" is exactly null suggested_tag: { type: Type.STRING, nullable: true }, reason: { type: Type.STRING, nullable: true }, }, // All fields required: existence is guaranteed, absence is expressed as null required: ["sentiment", "summary", "suggested_tag", "reason"], propertyOrdering: ["sentiment", "summary", "suggested_tag", "reason"],};const res = await ai.models.generateContent({ model: "gemini-flash-latest", contents: reviewText, config: { responseMimeType: "application/json", responseSchema: reviewSchema, },});
I also add one line to the prompt: "If there is no applicable value, return null rather than an empty string." Making the same promise twice — in the schema and in the prompt — visibly cut the empty-string filling. Even so, this does not drive the three shapes to zero; occasionally null and empty string still mix. That is precisely why the exit gate is needed.
Folding at the exit: the normalization gate
At the exit, fold null, empty string, undefined, and whitespace-only strings all into one representation (here, null). Once folded, keep going and shape the value into exactly what downstream expects — trimmed, lower-cased, length-capped.
type RawReview = { sentiment?: string | null; summary?: string | null; suggested_tag?: string | null; reason?: string | null;};type CleanReview = { sentiment: "positive" | "neutral" | "negative"; summary: string; suggested_tag: string | null; // absence is always unified to null reason: string | null;};// The one function that folds three shapes into onefunction normalizeOptional(v: unknown): string | null { if (v === null || v === undefined) return null; if (typeof v !== "string") return null; const t = v.trim(); return t.length === 0 ? null : t;}function normalizeReview(raw: RawReview): CleanReview { const sentiment = raw.sentiment?.trim().toLowerCase(); if (sentiment !== "positive" && sentiment !== "neutral" && sentiment !== "negative") { throw new Error(`unexpected sentiment: ${JSON.stringify(raw.sentiment)}`); } const tag = normalizeOptional(raw.suggested_tag); return { sentiment, summary: (raw.summary ?? "").trim(), suggested_tag: tag ? tag.toLowerCase().slice(0, 32) : null, reason: normalizeOptional(raw.reason), };}
After passing through normalizeOptional, an optional field is either a meaningful string or null — nothing else. The line that threw on .toUpperCase() can now be written against null, like tag ? tag.toUpperCase() : "UNTAGGED". The failure asymmetry disappears, and the downstream code gets much easier to read.
The numbers: what was happening across 3,200 reviews
Before adding the normalization gate, I saved one night of raw output as-is and counted how the optional field was represented. The sample was 3,200 App Store reviews in Japanese and English, on gemini-flash-latest.
Optional field representation
Count
Share
Value present (valid string)
2,608
81.5%
Null
402
12.6%
Empty string
121
3.8%
Missing key
69
2.2%
The three shapes that all mean "no value" add up to 592 rows, or 18.5% of the total. Of those, the null form that can throw is 12.6%, and the quietly-garbage empty string and missing key together are 6.0%. The night the batch died, it had stepped on one of that 12.6%.
Re-measuring after tightening the entry point, missing keys dropped to 0.3% and empty strings to 0.9%, with absence landing almost entirely on null. The remaining 1.2% is folded by the exit gate, so by the time data reaches downstream there are zero of the three shapes left. The lesson from the measurements was concrete: the entry alone, or the exit alone, each leaves a residue.
A final assertion before handing off
After normalizing, I slip a light assertion in right before the data goes to Firestore or the AdMob report rollup. If it trips here, a broken record is stopped before it enters the production datastore.
function assertClean(r: CleanReview, sourceId: string): CleanReview { // null is allowed, but undefined / empty / whitespace-only means a normalization leak for (const key of ["suggested_tag", "reason"] as const) { const v = r[key]; if (v !== null && (typeof v !== "string" || v.trim().length === 0)) { throw new Error(`normalize leak at ${key} (source=${sourceId}): ${JSON.stringify(v)}`); } } return r;}
This assertion doubles as a nightly health check on whether normalization is still working. If it fires in a batch one night, that is a signal that a model update — or a schema change on my side — has broken an assumption. I route this error to Slack and look at it first thing in the morning. An explicit stop is far easier to work with than data that silently turns murky.
Deciding how far to take it
A word on where to draw the line. My rule is: if a schema has even one optional field, I add both the nullable declaration at the entry and the normalization gate at the exit. The more fields there are, the more the combinations of three shapes multiply, so having one small function like normalizeOptional that every optional field passes through is easier to maintain than patching field by field.
At the same time, if you make an essential field nullable, you hand the model an escape hatch to omit a value. Something like sentiment, which must resolve to exactly one of a few values, stays locked down as enum plus required; only genuinely optional things are left optional. That judgment is what balances stable output against the amount of information you keep.
Three checks before adopting this
Before I add this two-stage setup to a new schema, I run through three checks every time. Most of my rework happened on the nights I skipped them.
Is this field genuinely optional? Anything that must resolve to exactly one value stays enum plus required, not nullable. Widening the optional surface too far hands the model an escape hatch to omit values.
Is the correct "no value" a null, or a meaningful default? For something like a tag, absence is natural, so null fits; for something like a count, 0 carries meaning, so 0 is the default — the normalization branch should match what the field means.
Where does it break downstream? Map the spots that throw on null and the spots that quietly rot on an empty string, then include them in the final assertion. Drawing that map of failure asymmetry first keeps root-cause work short.
Once a field passes these three checks and I wire up the entry and exit, adding more fields later fits the same shape.
I hope this helps anyone whose nightly batch died in the same spot. Thank you for reading.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.