additionalProperties を閉じないと、Schema 検証は検証になっていない
要点:検証を通過してもデータが clean とは限りません。Pydantic は未知フィールドを無視し、Zod は剥離し、Ajv は素通し—— hallucination された引数やタイプミスのキーが静かに通り抜けます。本稿は沈黙の受入れの3形態を示し、オブジェクトを閉じ、強制変換を制御し、ネスト全層で締める完全なレシピを提供します。

結論:デフォルトでは、主要なバリデータは未宣言フィールドを「拒否」ではなく「無視」します。additionalProperties: false を書かなければ、検証通過は「宣言したフィールドに問題がない」ことしか保証しません。
バリデータの最も危険な挙動はエラーを出すことではなく、エラーを出さないことです。タイプミスのキーやモデルが捏造した引数は検証層を静かに通過し、ビジネス層で爆発します。
一:「通過」する検証の実例
ツール呼び出し引数の契約に2つのフィールドだけを宣言した schema を考えます:
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"amount": { "type": "number" }
},
"required": ["order_id", "amount"]
}
{"order_id": "A1", "amount": 50, "reasn_code": "damaged"} を渡します——3番目のキーは reason_code のタイプミスです。Ajv のデフォルト設定での検証結果は通過。余分な reasn_code は拒否も除去もされず、そのままビジネスロジックに入ります。
これは Ajv のバグではありません。JSON Schema 仕様では additionalProperties のデフォルトは true です。「どのフィールドがあるか」は宣言しましたが「そのフィールドしかあってはならない」とは宣言していないので、バリデータは仕様通りに動いています。
二:実行時の3種類の「沈黙」
同じ欠落が言語ごとに異なる形で現れます:
| 実行時 | 未宣言キーのデフォルト挙動 | 結果 |
|---|---|---|
| Ajv(JS、デフォルト) | そのまま通す | 汚れたデータが業務層へ |
Zod z.object(TS) |
剥離(strip) | フィールドが消え、下流のオブジェクトが短くなる |
| Pydantic v2(Python) | 無視(extra="ignore") |
同上。ログに痕跡なし |
剥離は素通しより隐蔽的です。dev.to の Pydantic vs Zod 比較記事はこれを明確に述べています:両ライブラリはデフォルト状態で同じように——静かに——失敗します。
三:LLM ツール呼び出しが問題を拡大する
LLM のツール呼び出し引数はモデルが生成します。フィールド名を捏造し、"3" を数値として渡し、必須欄に曖昧な値を入れます。各プラットフォームは引数を JSON Schema で定義しますが、デフォルトでは schema のクローズを強制しません——strict モードは明示的に有効化する必要があり、文法制約はモデルの直接出力にしか適用されません。
防線はローカルに築くしかありません:
{
"order_id": 4411,
"refund_amount_usd": 50,
"reason_code": "damaged",
"urgent": "yes"
}
schema は最初の2フィールドのみ宣言。オブジェクトを閉じなければ後の2キーは素通り;閉じれば reason_code と urgent が拒否され、エラーは /reason_code パスを指します。
四:閉じ方:3つのエコシステム対照
Ajv:オブジェクトノードに additionalProperties: false を追加。
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const schema = {
type: "object",
properties: { order_id: { type: "string" }, amount: { type: "number" } },
required: ["order_id", "amount"],
additionalProperties: false,
};
const validate = ajv.compile(schema);
validate({ order_id: "A1", amount: 50, reasn_code: "x" }); // false
validate.errors[0].instancePath; // "/reasn_code"
Zod:z.object を z.strictObject に、または .strict() をチェーン。
Pydantic:ConfigDict(extra="forbid") を設定。
共通ルールは層ごとに閉じること。ネストされた各オブジェクトにも個別に additionalProperties: false が必要です。一層でも漏れれば、その層が新しい穴になります。
五:強制変換の方針も決める
Pydantic の lax モードは "5" を 5 に変換します。方針は3つ:
- 厳格モード:型が違えば拒否(Pydantic
strict=True、coerce なし Zod、Ajv はそもそも強制変換なし); - 制御付き変換:明示的な変換のみ許可(Zod
z.coerce.number())+範囲指定.positive().max(10000); - 完全放行:内部の既知データソースのみ。
LLM 引数のような信頼できない入力には 1 か 2 を選び、テストに書き込みます。意味が曖昧なフィールド(単位のない金額など)は refund_amount_usd のように単位をフィールド名に置きます。
六:ネストと配列のチェックリスト
| チェック点 | 做法 |
|---|---|
| トップレベル | additionalProperties: false |
| 各ネスト object | 同様に閉じる |
| 配列要素 | items 内のオブジェクトも閉じる |
| 列挙と範囲 | enum、minimum/maximum を補う |
| 生成器の出力 | キーワードを手動で補完 |
properties の宣言は required の宣言ではありません。生成器出力の詳細は《自動生成された JSON Schema がなぜ実データを拒否するのか》を参照。
七:拒否後のエラーをフィールドに向ける
allErrors: true で全越界フィールドを一度に報告し、verbose: true で具体パスを取得。instancePath の翻訳は《JSON Schema エラーメッセージの読み方》を参照。構文レベルの問題は schema 検証前に /parse で弾きます。
よくある質問
additionalProperties: false は厳しすぎますか?
契約次第です。自分で定義する入力なら閉じるべき;サードパーティの拡張余地があるデータはコアのみ閉じ、拡張領域を明示的に分けます。
Zod の strip の方が安全では?
strip は下流を守り、呼び出し側を害します。エラーは早く明示的に現れるべきで、strip はその逆です。
strict モードがあればローカル検証は不要?
必要です。schema 自体が未収口の可能性があり、文法制約がツール引数を覆わない場合もあるため、ローカルでの再検証はほぼ無コストです。
閉じた後、旧データが大量に落ちる?
それこそ暴露すべき問題です。allErrors で一覧化し、無害なキーは properties に追加、有害なものはデータ修正。通すためにスイッチを戻さないこと。
一行で済む兜底は?
ありませんが、最小の動作は:全 schema 定義を検索し、各 type: "object" に additionalProperties: false が付いているか確認することです。