From 4372b52432cd54cbfe429a418d22af7db033dbc3 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 5 Aug 2026 16:16:57 +0200 Subject: [PATCH] Collapse ObjectShapeType against HasPropertyType regardless of member 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) --- src/Type/TypeCombinator.php | 54 +++++++++++++++------ tests/PHPStan/Analyser/nsrt/bug-15047.php | 58 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15047.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 1c6b04d0d6..73d02dace7 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1740,6 +1740,7 @@ public static function doIntersect(Type ...$types): Type $hasOffsetValueTypeCount = 0; $typesCount = count($types); $typesNeedSorting = false; + $hasPropertyType = false; for ($i = 0; $i < $typesCount; $i++) { $type = $types[$i]; @@ -1747,6 +1748,10 @@ public static function doIntersect(Type ...$types): Type $typesNeedSorting = true; } + if ($type instanceof HasPropertyType) { + $hasPropertyType = true; + } + if ($type instanceof IntersectionType && !$type instanceof TemplateType) { // transform A & (B & C) to A & B & C array_splice($types, $i--, 1, $type->getTypes()); @@ -1785,6 +1790,41 @@ public static function doIntersect(Type ...$types): Type }); } + // Resolve object-shape optional keys that a HasPropertyType asserts are present before the + // reduction loop below. In that loop the generic supertype dedup can drop a HasPropertyType + // as redundant against a dynamic-property class such as stdClass (which reports every + // property as present) before it is ever paired with the object shape. Which of the two + // fires first depends on the member order, so the collapse runs here, where order does not + // change the result of what is meant to be an order-independent value. Gated on the presence + // of a HasPropertyType so the common intersection pays only the flag check set above. + if ($hasPropertyType) { + for ($i = 0; $i < $typesCount; $i++) { + for ($j = $i + 1; $j < $typesCount; $j++) { + if ( + $types[$i] instanceof ObjectShapeType + && $types[$j] instanceof HasPropertyType + && !$types[$i]->hasInstanceProperty($types[$j]->getPropertyName())->no() + ) { + $types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName()); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ( + $types[$j] instanceof ObjectShapeType + && $types[$i] instanceof HasPropertyType + && !$types[$j]->hasInstanceProperty($types[$i]->getPropertyName())->no() + ) { + $types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName()); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + } + } + // transform IntegerType & ConstantIntegerType to ConstantIntegerType // transform Child & Parent to Child // transform Object & ~null to Object @@ -1949,20 +1989,6 @@ public static function doIntersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) { - $types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName()); - array_splice($types, $j--, 1); - $typesCount--; - continue; - } - - if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) { - $types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName()); - array_splice($types, $i--, 1); - $typesCount--; - continue 2; - } - $constArrayIsI = $types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType); $constArrayIsJ = $types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType); if ($constArrayIsI || $constArrayIsJ) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15047.php b/tests/PHPStan/Analyser/nsrt/bug-15047.php new file mode 100644 index 0000000000..d4782336fd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15047.php @@ -0,0 +1,58 @@ +untilYear = $untilYear; + } + + /** + * @param stdClass&object{since_year:int,until_year?:int} $data + */ + protected static function fromObjectInternal(stdClass $data): self + { + assertType('object{since_year: int, until_year?: int}&stdClass', $data); + assertType('int|null', $data->until_year ?? null); + assertType('int', $data->since_year); + + if (isset($data->until_year)) { + assertType('object{since_year: int, until_year: int}&stdClass', $data); + assertType('int', $data->until_year); + } + + return new self($data->until_year ?? null); + } + +} + +/** + * The member order of the intersection must not change the result: whether the object shape or + * stdClass is written first, isset()/?? narrowing resolves the optional key to its declared type. + * + * @param stdClass&object{u?:int} $stdFirst + * @param object{u?:int}&stdClass $shapeFirst + */ +function orderIndependent($stdFirst, $shapeFirst): void +{ + assertType('int|null', $stdFirst->u ?? null); + assertType('int|null', $shapeFirst->u ?? null); +}