Summary
{"type": ["object"]} is exactly equivalent to {"type": "object"} per JSON Schema — a
single-element type array permits exactly that one type. ObjectShapeResolver treats the two as
equivalent and has a dedicated unit-test row saying so
('multi-type array containing only object' => ObjectShape::ObjectAsserting), but the rest of the
pipeline does not. The result is a schema that is classified as a definite object and then either
fails with an unrelated generic error or is skipped without any message at all.
Pre-existing (reproduces identically on master); found while reviewing #166, whose new
representability check makes the divergence visible because it now approves schemas the pipeline
then refuses to build.
Reproductions
1. Composition branch — generic, misattributed error
{
"type": "object",
"properties": {
"p": {
"allOf": [
{
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
},
{
"type": ["object"],
"required": ["name"]
}
]
}
}
}
SchemaException: No nested schema for composed property Root_P in file <file> found at line 4, column 10
The schema is satisfiable and both branches are object schemas. Writing the second branch as
"type": "object" generates without complaint.
2. Root composition — passes the representability check, then dies
{
"allOf": [
{
"type": ["object"],
"properties": {
"name": {
"type": "string"
}
}
}
]
}
SchemaException: No nested schema for composed property Root in file <file> found at line 1, column 1
SchemaProcessor::checkObjectRepresentability() classifies this root ObjectAsserting and lets it
through — so the check reports the schema as representable moments before the pipeline proves it is
not. That is the specific reason this is worth fixing rather than tolerating: the classifier and the
routing disagree, and the classifier is the one that is right.
3. Bare root — no class and no diagnostic at all
{
"type": ["object"],
"properties": {
"name": {
"type": "string"
}
}
}
Produces nothing: no class, no warning, no error. SchemaProcessor::processSchema()'s gate is
(!isset($jsonSchema->getJson()['type']) || $jsonSchema->getJson()['type'] !== 'object') &&
!array_intersect(array_keys($jsonSchema->getJson()), ['anyOf', 'allOf', 'oneOf', 'if', '$ref'])
a strict string comparison, so an array-valued type never matches, and with no composition keyword
present the file is silently skipped.
Cause
PropertyFactory::create() sends any array-valued type to createMultiTypeProperty(), regardless
of how many entries it has. That method builds one sub-property per listed type and delegates the
object entry to createObjectProperty() — so the sub-property gets the nested schema while the
outer property, the one the composition machinery actually sees, gets none.
It also only calls finalizeMultiTypeProperty() once $collectedTypes is non-empty, and
$collectedTypes is fed from TypeCheckInterface validators. An object sub-property contributes an
InstanceOfValidator instead, so for an object-only list the collection stays empty, finalization
never runs and the property never receives a type either. Both signals the composition machinery
relies on (getNestedSchema(), getType()) are therefore null, which is what produces the
"No nested schema for composed property" message.
Suggested fix
Normalise a single-element type array to its scalar form before dispatch in
PropertyFactory::create(), and make processSchema()'s gate compare against the normalised type
rather than the raw string. Both are pure normalisations — ["object"] and "object" are the same
schema — and normalising early keeps every downstream consumer (routing, gate, classifier) agreeing
by construction instead of each restating the rule.
Note the normalisation applies to every type, not just object: ["string"] would stop going
through the multi-type path too. That is correct, but it changes which validator reports a type
mismatch and therefore its message wording, so existing message assertions need a sweep before the
change lands.
Not covered by the existing follow-up
The multi-type audit in the implied-object follow-up (#181, Part 2 Question A) lists {"type": "string"} roots, bare object-keyword roots and not as gate cases to review, but not multi-type
declarations — and the reproductions above show the divergence has consequences beyond the gate.
Summary
{"type": ["object"]}is exactly equivalent to{"type": "object"}per JSON Schema — asingle-element type array permits exactly that one type.
ObjectShapeResolvertreats the two asequivalent and has a dedicated unit-test row saying so
(
'multi-type array containing only object' => ObjectShape::ObjectAsserting), but the rest of thepipeline does not. The result is a schema that is classified as a definite object and then either
fails with an unrelated generic error or is skipped without any message at all.
Pre-existing (reproduces identically on
master); found while reviewing #166, whose newrepresentability check makes the divergence visible because it now approves schemas the pipeline
then refuses to build.
Reproductions
1. Composition branch — generic, misattributed error
{ "type": "object", "properties": { "p": { "allOf": [ { "type": "object", "properties": { "name": { "type": "string" } } }, { "type": ["object"], "required": ["name"] } ] } } }The schema is satisfiable and both branches are object schemas. Writing the second branch as
"type": "object"generates without complaint.2. Root composition — passes the representability check, then dies
{ "allOf": [ { "type": ["object"], "properties": { "name": { "type": "string" } } } ] }SchemaProcessor::checkObjectRepresentability()classifies this rootObjectAssertingand lets itthrough — so the check reports the schema as representable moments before the pipeline proves it is
not. That is the specific reason this is worth fixing rather than tolerating: the classifier and the
routing disagree, and the classifier is the one that is right.
3. Bare root — no class and no diagnostic at all
{ "type": ["object"], "properties": { "name": { "type": "string" } } }Produces nothing: no class, no warning, no error.
SchemaProcessor::processSchema()'s gate isa strict string comparison, so an array-valued
typenever matches, and with no composition keywordpresent the file is silently skipped.
Cause
PropertyFactory::create()sends any array-valuedtypetocreateMultiTypeProperty(), regardlessof how many entries it has. That method builds one sub-property per listed type and delegates the
objectentry tocreateObjectProperty()— so the sub-property gets the nested schema while theouter property, the one the composition machinery actually sees, gets none.
It also only calls
finalizeMultiTypeProperty()once$collectedTypesis non-empty, and$collectedTypesis fed fromTypeCheckInterfacevalidators. An object sub-property contributes anInstanceOfValidatorinstead, so for an object-only list the collection stays empty, finalizationnever runs and the property never receives a type either. Both signals the composition machinery
relies on (
getNestedSchema(),getType()) are therefore null, which is what produces the"No nested schema for composed property" message.
Suggested fix
Normalise a single-element type array to its scalar form before dispatch in
PropertyFactory::create(), and makeprocessSchema()'s gate compare against the normalised typerather than the raw string. Both are pure normalisations —
["object"]and"object"are the sameschema — and normalising early keeps every downstream consumer (routing, gate, classifier) agreeing
by construction instead of each restating the rule.
Note the normalisation applies to every type, not just
object:["string"]would stop goingthrough the multi-type path too. That is correct, but it changes which validator reports a type
mismatch and therefore its message wording, so existing message assertions need a sweep before the
change lands.
Not covered by the existing follow-up
The multi-type audit in the implied-object follow-up (#181, Part 2 Question A) lists
{"type": "string"}roots, bare object-keyword roots andnotas gate cases to review, but not multi-typedeclarations — and the reproductions above show the divergence has consequences beyond the gate.