Skip to content

{"type": ["object"]} is classified as an object but cannot be routed as one #189

Description

@wol-soft

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions