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); +}