Optional Fields, Nulls and Unions: Where Schemas Break
4 min read · updated August 3, 2026
JSON Schema was designed to validate documents that already exist. Constrained decoding uses it to generate documents that do not. Four constructs do not survive that change of direction intact.
1. Optional fields
In ordinary JSON Schema, a property absent from required is optional. Under generation this is a genuinely awkward instruction: “emit this key, or do not, as you see fit.” OpenAI’s strict mode resolves the awkwardness by forbidding it — the docs state that all fields must be listed in required, and the request fails otherwise with a message of the form:
400 Invalid schema for response_format 'extraction': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'customer_name'.
The prescribed replacement is a nullable type: keep the key required and let its value be null. This is better than optionality for reasons beyond compliance. A required-and-null field is a decision the model made and you can count; a missing key is indistinguishable from a truncated response, a schema change you forgot to deploy, or a bug in your own serialisation.
The same applies to additionalProperties. Strict mode requires it set to false on every object, and a schema that omits it gets an analogous 400 naming the context path. Generate your schemas rather than hand-writing them and this becomes a one-line fix in one place — the walker that does it is here.
There is a second-order effect worth understanding, because it changes what your data means. Under a nullable-and-required schema the model must produce a token for every field on every document, so a field it would previously have omitted now forces a decision: is this genuinely absent, or did I miss it? That decision is visible to you as a null you can count, group by document type, and act on. With optional fields the same uncertainty leaves no trace at all — the key is simply not there, and an absence rate is not a metric you can compute against a schema that permits absence.
2. Null, and its four impostors
Even once null is legal, there are five distinguishable things a model can put in a field meaning “nothing here”, and the type system permits four of them:
| Value | Description |
|---|---|
| null | The one you want. Unambiguous, machine-checkable. |
| "" | Empty string. Valid for type string, silently sorts and joins differently, and equals nothing in SQL comparisons. |
| "N/A" / "unknown" / "none" | Prose in a data field. Passes every type check you have. Reaches your database as a customer named 'unknown'. |
| 0 | For numbers, the worst one. Sums, averages and totals absorb it without complaint. |
| (key omitted) | Rejected at request time by strict modes, which is a mercy. |
The fix is one sentence per nullable field’s description: “null if the document does not state this — do not write ‘N/A’ or an empty string.” Then assert it. A validator rule that rejects the string "N/A" anywhere in the record catches this class permanently and costs four lines.
3. Unions
anyOf is generally supported; oneOf and allOf frequently are not, and the reason is mechanical rather than arbitrary. oneOf means “valid against exactly one branch”, which cannot be decided until the document is finished — a left-to-right decoder cannot mask on it. allOf requires intersecting schemas before compilation. anyOf is decidable prefix by prefix, so it is the one that survives.
Even with anyOf, an untagged union asks the model to pick a branch implicitly. Tag it with a const discriminator emitted first, so the branch is a decision the model states before it is committed to the branch’s fields:
{
"anyOf": [
{ "type": "object", "additionalProperties": false,
"required": ["kind", "iso_date"],
"properties": {
"kind": { "const": "absolute" },
"iso_date": { "type": "string", "description": "YYYY-MM-DD" } } },
{ "type": "object", "additionalProperties": false,
"required": ["kind", "phrase"],
"properties": {
"kind": { "const": "relative" },
"phrase": { "type": "string", "description": "e.g. 'net 30 days from issue'" } } }
]
}Your validator then dispatches on kind instead of trying each branch, and a mis-tagged record is a loud failure rather than a silent fallthrough. Deeply nested unions are where provider subsets differ most; if a union is more than one level down, check that your provider accepts it before designing around it.
4. Validation keywords that vanish
This is the quiet one. A large part of JSON Schema exists to constrain values rather than shape: minLength, maxLength, pattern, format, minimum, maximum, multipleOf, minItems, maxItems, uniqueItems. Several of these sit outside the documented supported keyword set for hosted strict modes, and the failure is not uniform: some deployments reject the schema, and some accept it and quietly drop the keyword.
A dropped minItems: 3 is a bug that looks like a model failure. You wrote a constraint, the API returned 200, you got two items, and nothing anywhere said the constraint was never applied. Assume any value-level keyword may be ignored, put its meaning in the description where the model will read it, and enforce it in your own validator afterwards. A probe that finds which ones your endpoint drops takes about a minute to run.
Some of these have a genuine schema-level workaround and some do not, and the distinction is about whether the constraint is expressible as a shape. A pattern matching a fixed set of prefixes can often become an enum or a tagged anyOf, which is enforceable. A bounded integer can become an enum of the permitted values when there are few of them — five-point ratings and calendar quarters are the obvious cases, and they are worth doing, because the model then cannot return a six. A minimum length, an arbitrary regular expression or a uniqueness requirement over a generated array cannot be expressed this way and belongs entirely in your validator.
format deserves singling out because it is treacherous even where it is accepted. In JSON Schema itself, format is an annotation rather than an assertion by default — a validator is permitted to ignore it — so a value can pass local validation and be malformed. Never rely on format: "date" for either generation or checking. Say “YYYY-MM-DD” in the description, then parse the string yourself and fail on the exception.
The errors, and what they mean
| What you see | Description |
|---|---|
| 400, 'required' ... Missing 'x' | Strict mode wants every property required. Add it and make the type nullable. |
| 400, 'additionalProperties' is required to be supplied and to be false | An object in your schema — often one generated from a type definition — left it out. Walk the schema and set it everywhere. |
| 400 naming an unsupported keyword | The good outcome. Remove the keyword, move its meaning into the description, validate locally. |
| 200 with output violating a keyword you set | The keyword was dropped, not enforced. Treat the schema as advisory from here and validate everything. |
| 200 with every field null | Not a schema problem. Either the document genuinely lacks the data or your descriptions do not match what is on the page. |