Skip to content

Collapse ObjectShapeType against HasPropertyType regardless of member order - #6184

Open
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:fix-intersection-hasproperty-order
Open

Collapse ObjectShapeType against HasPropertyType regardless of member order#6184
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:fix-intersection-hasproperty-order

Conversation

@SanderMuller

Copy link
Copy Markdown
Contributor

When a parameter is typed \stdClass&object{u?:int} and its value goes through isset()/?? narrowing, PHPStan should resolve the optional key to its declared type. Since 2.2.6 it fell back to mixed instead:

abstract class AbstractJsonRepresentation
{
    /** @param \stdClass $data */
    abstract protected static function fromObjectInternal(\stdClass $data): self;
}

final class MissalYearLimits extends AbstractJsonRepresentation
{
    private ?int $until_year;

    /** @param \stdClass&object{since_year:int,until_year?:int} $data */
    protected static function fromObjectInternal(\stdClass $data): self
    {
        // 2.2.5: int|null   2.2.6+: mixed  -> "argument.type" false positive at level 10
        return new self($data->until_year ?? null);
    }
}

Root cause

isset($data->until_year) narrows $data by intersecting it with a HasPropertyType for the key, and TypeCombinator::intersect() is supposed to collapse object{until_year?:int} & hasProperty(until_year) into object{until_year:int}, so the read is int.

That collapse lived in the pairwise reduction loop, reached only after the generic supertype dedup. The parameter also carries stdClass, a dynamic-property class that reports every property as present, so hasProperty(until_year) is a supertype of it and the dedup removes the HasPropertyType as redundant against stdClass. Whether the dedup drops it first or the object shape collapses it first depends on the order of the members in the intersection.

While intersection members were still sorted in place, describing the type happened to reorder them so the object shape came first and won the race. Once that in-place mutation was removed (correctly, so a type's value no longer depends on what has been called on it) the construction order took over, stdClass came first, and its mixed dominated.

Note the bare read $data->until_year without isset/?? was already mixed before and after 2.2.6. That is a separate, pre-existing gap and not what this fixes; this change is about the isset/?? narrowing path only.

Fix

Move the object-shape / HasPropertyType collapse into its own pass before the reduction loop, so member order no longer decides the outcome. Two details keep it behaviour-preserving otherwise:

  • It only fires when the shape has, or may have, the key (hasInstanceProperty(...) is not no). A sealed shape intersected with a HasPropertyType for a key it cannot have is left alone, so it still reduces to never in the loop below, as it did before.
  • The pass is gated on a HasPropertyType being present in the intersection, a flag computed in the existing flatten loop, so an intersection without one pays only that flag check and never enters the extra loop.

The array analogue (ConstantArrayType & HasOffsetType) is not affected: there is no universal-offset type that reports every offset as present, so nothing absorbs the HasOffsetType before the offset is made required. Verified against real 2.2.5/2.2.6 builds.

Closes phpstan/phpstan#15047

… order

An intersection of an object shape carrying an optional key with a
HasPropertyType for that key - as produced by isset()/?? narrowing, e.g.
`stdClass&object{u?:int}` narrowed by `isset($x->u)` - is supposed to resolve
the optional key to its declared type, so that `$x->u ?? null` is `int|null`.

That relied on member order. The collapse
`ObjectShapeType & HasPropertyType -> makePropertyRequired()` sat in the
reduction loop, reached only after the generic supertype dedup. When the
intersection also contains a dynamic-property class such as stdClass, which
reports every property as present, HasPropertyType is a supertype of it and the
dedup splices HasPropertyType out before it is ever paired with the object
shape. Which of the two fires first depends on the order of the members, so the
optional key stayed optional whenever stdClass happened to come first and the
read fell back to the class's mixed. While intersection members were still
sorted in place this was masked - describing the type reordered them so the
shape came first; once that mutation was removed the construction order won.

Move the collapse into its own pass before the reduction loop so member order
no longer decides the result. Guard it with hasInstanceProperty(): when the
shape does not have the key it is left untouched, so a sealed shape intersected
with a HasPropertyType for a key it cannot have still reduces to never in the
loop below, as before.

The array analogue (ConstantArrayType & HasOffsetType) is unaffected: there is
no universal-offset crate reporting every offset as present, so nothing absorbs
the HasOffsetType before the offset is made required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
*/
protected static function fromObjectInternal(stdClass $data): self
{
assertType('object{since_year: int, until_year?: int}&stdClass', $data);

@staabm staabm Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't object{...} already imply stdClass? can't we just drop stdClass from the intersection when building the type for the phpdoc as it is already redundant?

skip that. I missremembered

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression (2.2.6+): overriding method's narrower @param object shape is ignored when the abstract parent declares a wider @param

2 participants