Without additionalProperties, Schema Validation Validates Nothing
Bottom line: A passing validation does not mean clean data. Pydantic ignores unknown fields by default, Zod strips them, and Ajv lets them through — hallucinated parameters and typo'd keys all pass silently. This article shows the three forms of silent acceptance, then gives complete recipes for closing objects, controlling coercion, and tightening every nesting level.

The takeaway: by default, mainstream validators ignore undeclared fields rather than reject them. Without additionalProperties: false, a passing validation only proves that the declared fields are fine — not that the data contains nothing extra.
The most dangerous behavior of a validator is not raising an error; it is staying silent. A typo'd key or a hallucinated parameter passes through the validation layer without a sound and only explodes in business logic — by then, it is several layers away from the source.
One: a validation that "passes"
Suppose you have a contract for tool call arguments that declares two fields:
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"amount": { "type": "number" }
},
"required": ["order_id", "amount"]
}
Now feed it {"order_id": "A1", "amount": 50, "reasn_code": "damaged"} — note that the third key is misspelled; the intent was reason_code. Validate it with Ajv's default configuration and the result is a pass. The extra reasn_code is neither rejected nor removed; it flows into business logic untouched.
This is not an Ajv bug. In the JSON Schema specification, the default value of additionalProperties is true: undeclared fields are allowed. You declared which fields exist, but not that only those fields may exist, and the validator did exactly what the spec says.
Two: three runtime flavors of silence
The same gap behaves differently across languages, which is where most teams get burned:
| Runtime | Default behavior for undeclared keys | Consequence |
|---|---|---|
| Ajv (JS, default config) | Lets them through | Dirty data reaches business logic |
Zod z.object (TS) |
Strips them | Fields vanish; downstream receives a shorter object |
| Pydantic v2 (Python) | Ignores them (extra="ignore") |
Same, and the log shows nothing |
Stripping is stealthier than letting fields through. When fields pass through, downstream code can at least see them; after stripping, the caller believes it sent reason_code, the handler receives a shorter payload, and nothing in between complains. The dev.to comparison of Pydantic and Zod puts it well: out of the box, both libraries fail the same way — silently.
Three: LLM tool calling makes the gap expensive
When humans write the calling code, an extra field is usually a typo — rare and harmless. But LLM tool call arguments are generated by a model, which will confidently invent field names, pass "3" where a number belongs, and fill required fields with vague values. OpenAI, Anthropic, and other platforms express parameters as JSON Schema, yet by default they do not force your schema to be closed — strict mode must be enabled explicitly, and platform-side grammar constraints only apply to the model's direct output, not to the arguments your code executes locally.
So the defense line has to live in your own code. A realistic payload from a hallucinating model:
{
"order_id": 4411,
"refund_amount_usd": 50,
"reason_code": "damaged",
"urgent": "yes"
}
Your schema declares only the first two fields. With the object left open, both extra keys sail through. Closed, reason_code and urgent are rejected, and the error points straight at the /reason_code path.
Four: how to close an object, in three ecosystems
Ajv: add additionalProperties: false on the object node.
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: swap z.object for z.strictObject, or chain .strict().
Pydantic: configure the model with ConfigDict(extra="forbid").
All three share one rule: closing is per level. Closing the outer object does not propagate into nested object fields; every nested object needs its own additionalProperties: false (in Pydantic, every nested model needs extra="forbid"). Miss one level, and that level is the new hole.
Five: decide the coercion policy while you are at it
Closing extra fields is step one. There is an equally quiet gap: type coercion. Pydantic's lax mode converts "5" into 5, and YAML-family parsers turn yes into true. There are three validation policies; any of them works, but you must pick one explicitly and encode it in tests:
- Strict mode: reject wrong types outright (Pydantic
strict=True, Zod without coerce, Ajv does not coerce at all); - Controlled coercion: allow only explicit conversions (Zod
z.coerce.number()), bounded with.positive().max(10000); - Permissive: only for internal, known-controlled data sources.
For untrusted input such as LLM arguments, pick 1 or 2. For fields with ambiguous semantics — an amount without a unit, say — put the unit into the field name (refund_amount_usd); that is more reliable than a comment.
Six: nesting and arrays — a tightening checklist
| Checkpoint | What to do |
|---|---|
| Top-level object | additionalProperties: false |
| Every nested object | Close it too, level by level |
| Array elements | Objects inside items need closing as well |
| Enums and ranges | Add enum, minimum/maximum to stop semantic drift |
| Generator output | Fill in keywords by hand; do not trust defaults |
Item four matters most: declaring properties is not declaring required, and declaring a type is not restricting the value. Generated schemas routinely omit these keywords; the mechanics are measured in detail in our article on why auto-generated schemas reject real data, so this one does not repeat them.
Seven: after the rejection — point the error back at the field
Once objects are closed, rejection is deliberate, and error message quality determines debugging speed. Ajv with allErrors: true reports every offending field in one run instead of the first; verbose: true adds concrete paths. Turning instancePath into field names users understand is covered systematically in our article on reading Ajv validation errors.
Syntax-level problems (trailing commas, bad quotes) should be caught before schema validation ever runs, by tools like our online parser — the two layers have separate jobs.
FAQ
Won't additionalProperties: false be too strict and reject valid data?
It depends on the contract. For arguments you define yourself, the field list is yours to decide — close it. For loose third-party data with extension points (config files, say), you can close only the core object and leave an extension area open, as long as you mark which level is the extension area.
Zod stripping unknown keys sounds safer — why is it a trap?
Stripping protects downstream code at the expense of the caller. The model or upstream service believes it sent a field; it was silently dropped; the whole chain reports nothing; and the problem surfaces only when business results go wrong. The first principle of validation is to make errors surface early and explicitly. Stripping is the opposite.
If the platform's strict mode is on, do I still need local validation?
Yes. Strict mode ensures the model's output matches your schema, but your schema itself may be open (additionalProperties defaults to true), and on some platforms grammar constraints cover only the model's direct output, not tool arguments. Re-validating locally with Ajv, Pydantic, or Zod costs almost nothing.
Old records now fail in bulk after closing additionalProperties — what now?
That means the old data really does contain undeclared fields, which is exactly what you wanted to expose. Run once with allErrors to inventory which keys cross the line, add the harmless ones to properties, fix the harmful ones — and do not flip the switch back off just to make things pass.
Is there a one-line fix?
No, but there is a minimal move: search your codebase for every schema definition and confirm that each type: "object" is followed by additionalProperties: false. For every one that is not, evaluate whether it should be.