先週、個人開発で回している夜間バッチが、明け方に静かに止まっていました。App Store のレビューを Gemini の structured output で分類し、要約と改善タグを Firestore に書き込むだけの小さなパイプラインです。ログを追うと、suggested_tag を大文字化する一行で Cannot read properties of null が出ていました。
前日まで動いていた処理が、モデルの出力が少しゆらいだだけで止まる。原因は、任意フィールドが「null」「空文字」「キーそのものが無い」という三つの姿でばらついていたことでした。responseSchema を渡しているのに、なぜこうなるのか。入口と出口の両方に手を入れて、ようやく落ち着いた過程を残します。
三様にぶれる、とはどういうことか
同じスキーマ、同じプロンプトでも、任意フィールドの表現は一つに定まりません。手元のログから、suggested_tag(改善タグ・任意)が返ってきた三つの形を並べます。
ケース 実際の出力 JavaScript での型 素朴なコードの挙動
値あり "suggested_tag": "dark-mode"string 正常
null "suggested_tag": nullnull(object 扱い) .toUpperCase() で例外
空文字 "suggested_tag": ""string(長さ0) 空のタグが混入
キー欠落 キー自体が無い undefined 後段の分岐を素通り
厄介なのは、三つとも「タグが無い」という同じ意味を表しているのに、下流での壊れ方が違うところです。null は例外を投げ、空文字は無害な顔をして無意味なデータを増やし、キー欠落は条件分岐をすり抜けて別の不具合として遅れて表面化します。原因の切り分けが難しいのは、この壊れ方の非対称性が理由でした。
なぜ任意フィールドは三態になるのか
responseSchema はフィールドの型と構造を制約しますが、「値が無いときにどう表現するか」までは一意に決めてくれません。ここが誤解しやすい点でした。
第一に、required に含めていないフィールドは、モデルの判断で丸ごと省略され得ます。第二に、required に入れていても、nullable を明示していないと、モデルは「値が無い」ことを空文字で埋めたり null で返したりと、そのときの生成に引きずられます。第三に、フィールド順を propertyOrdering で固定しても、それは順序の安定であって値の表現の安定ではありません。順序の話と値の表現の話は別軸です。この点はpropertyOrdering でフィールド順を安定させる話 で扱いましたが、今回のぶれはその先の問題でした。
つまり、スキーマだけでは三態を一態には縮められません。入口(スキーマ設計)でぶれの幅を狭め、出口(出力後の正規化)で残りを畳む。この二段構えが要ります。
入口を締める: responseSchema の nullable と required
まず入口です。任意フィールドを「省略され得るもの」ではなく「必ず存在し、値が無いときは null」と決め切ります。required に含めたうえで nullable: true を宣言すると、キー欠落と空文字のぶれが大きく減りました。
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 },
// 任意フィールドは required に入れつつ nullable で「値なし=null」を一意に決める
suggested_tag: { type: Type. STRING , nullable: true },
reason: { type: Type. STRING , nullable: true },
},
// 全フィールドを required に。存在は保証し、無い値は 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,
},
});
あわせて、プロンプト側にも一行だけ約束を書きます。「該当する値が無い場合は空文字ではなく null を返してください」。スキーマとプロンプトで同じ約束を二重に伝えると、空文字での埋めが目に見えて減りました。ただし、これで三態がゼロになるわけではありません。稀に null と空文字が混ざります。だからこそ出口のゲートが要ります。
出口で畳む: 正規化ゲートの実装
出口では、null・空文字・undefined・空白のみの文字列を、すべて一つの表現(ここでは null)へ畳みます。畳んだうえで、下流が期待する形(trim 済み・小文字化・長さ上限)まで一気に整えます。
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 ; // 値が無い場合は必ず null に統一
reason : string | null ;
};
// 三態を一態へ畳む中心の関数
function 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),
};
}
normalizeOptional を通した後は、任意フィールドは「意味のある文字列」か null のどちらかしかありません。冒頭の .toUpperCase() で落ちた箇所も、tag ? tag.toUpperCase() : "UNTAGGED" のように null 前提で書けます。壊れ方の非対称性が消え、下流のコードが一気に読みやすくなりました。
実測: 3,200件のレビュー処理で何が起きていたか
正規化ゲートを入れる前に、一晩分の生出力をそのまま保存して、任意フィールドの表現を数えてみました。対象は App Store の日英レビュー 3,200件、モデルは gemini-flash-latest です。
任意フィールドの表現 件数 割合
値あり(有効な文字列) 2,608件 81.5%
null 402件 12.6%
空文字 121件 3.8%
キー欠落 69件 2.2%
「値なし」を意味する三態を合わせると 592件、全体の 18.5% です。このうち例外を投げ得る null が 12.6%、静かにゴミを増やす空文字とキー欠落が合わせて 6.0%。夜間バッチが止まっていたのは、この 12.6% のどれか一件を踏んだ日でした。
入口を締めた後に再計測すると、キー欠落は 0.3%、空文字は 0.9% まで下がり、値なしはほぼ null に寄りました。残った 1.2% を出口のゲートが畳むので、下流に届く時点での三態は 0 件です。入口だけでも出口だけでも取りこぼしが残る、というのが実測から得た手応えでした。
下流に渡す前の最終アサーション
正規化した後、Firestore や AdMob レポートの集計へ渡す直前に、軽いアサーションを一枚挟みます。ここで弾ければ、壊れたレコードが本番のデータストアへ入る前に止められます。
function assertClean ( r : CleanReview , sourceId : string ) : CleanReview {
// null は許容するが、undefined・空文字・空白のみは「畳み残し」なので即エラー
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;
}
このアサーションは「正規化が効いているか」を毎晩検証する健康診断も兼ねています。ある夜これがまとめて発火したら、モデル更新か、こちらのスキーマ変更で前提が崩れた合図です。私はこのエラーを Slack に流し、翌朝いちばんに見るようにしています。沈黙のうちにデータが濁っていくより、明示的に止まってくれるほうがずっと扱いやすいからです。
運用に組み込むときの判断
最後に、どこまでやるかの線引きです。私は、任意フィールドを一つでも持つスキーマなら、入口の nullable 宣言と出口の正規化ゲートは両方入れる、という方針を採っています。フィールドが増えるほど三態の組み合わせが指数的に効いてくるので、フィールドごとに手当てするより、normalizeOptional のような小さな関数を一つ用意して全任意フィールドに通すほうが保守が楽でした。
一方で、required にできる本質的な項目まで nullable にしてしまうと、モデルに「値を省く逃げ道」を与えてしまいます。sentiment のように必ず一つに定まるべきものは enum + required のまま締め、任意なのは本当に任意なものだけに絞る。この見極めが、出力の安定と情報量のバランスを決めます。
導入前に確かめる3点
新しいスキーマにこの二段構えを入れる前に、私は次の3点を毎回確認しています。手戻りの多くは、この確認を飛ばしたときに起きていました。
そのフィールドは本当に任意か。値が必ず一つに定まるものは enum + required で締め、nullable にしない。任意の範囲を広げすぎると、モデルに値を省く逃げ道を与えてしまいます。
「値なし」の正解は null か、それとも意味のある既定値か。タグのように無い方が自然なものは null、件数のように 0 が意味を持つものは 0 を既定値にし、正規化関数の分岐をフィールドの意味に合わせます。
下流のどこで壊れるか。null で例外を投げる箇所、空文字で無害に濁る箇所を洗い出し、最終アサーションで止める対象に含めます。壊れ方の非対称性を先に地図にしておくと、原因調査が短く済みます。
この3点を通したうえで入口と出口を組むと、後からフィールドが増えても同じ型で対応できました。
同じところで夜間バッチを止めてしまった方の助けになればと思います。お読みいただきありがとうございました。