Skip to content

Commit 4372b52

Browse files
SanderMullerclaude
andcommitted
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) <noreply@anthropic.com>
1 parent c2dbd45 commit 4372b52

2 files changed

Lines changed: 98 additions & 14 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,13 +1740,18 @@ public static function doIntersect(Type ...$types): Type
17401740
$hasOffsetValueTypeCount = 0;
17411741
$typesCount = count($types);
17421742
$typesNeedSorting = false;
1743+
$hasPropertyType = false;
17431744
for ($i = 0; $i < $typesCount; $i++) {
17441745
$type = $types[$i];
17451746

17461747
if ($type instanceof SubtractableType || $type instanceof ConstantArrayType) {
17471748
$typesNeedSorting = true;
17481749
}
17491750

1751+
if ($type instanceof HasPropertyType) {
1752+
$hasPropertyType = true;
1753+
}
1754+
17501755
if ($type instanceof IntersectionType && !$type instanceof TemplateType) {
17511756
// transform A & (B & C) to A & B & C
17521757
array_splice($types, $i--, 1, $type->getTypes());
@@ -1785,6 +1790,41 @@ public static function doIntersect(Type ...$types): Type
17851790
});
17861791
}
17871792

1793+
// Resolve object-shape optional keys that a HasPropertyType asserts are present before the
1794+
// reduction loop below. In that loop the generic supertype dedup can drop a HasPropertyType
1795+
// as redundant against a dynamic-property class such as stdClass (which reports every
1796+
// property as present) before it is ever paired with the object shape. Which of the two
1797+
// fires first depends on the member order, so the collapse runs here, where order does not
1798+
// change the result of what is meant to be an order-independent value. Gated on the presence
1799+
// of a HasPropertyType so the common intersection pays only the flag check set above.
1800+
if ($hasPropertyType) {
1801+
for ($i = 0; $i < $typesCount; $i++) {
1802+
for ($j = $i + 1; $j < $typesCount; $j++) {
1803+
if (
1804+
$types[$i] instanceof ObjectShapeType
1805+
&& $types[$j] instanceof HasPropertyType
1806+
&& !$types[$i]->hasInstanceProperty($types[$j]->getPropertyName())->no()
1807+
) {
1808+
$types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName());
1809+
array_splice($types, $j--, 1);
1810+
$typesCount--;
1811+
continue;
1812+
}
1813+
1814+
if (
1815+
$types[$j] instanceof ObjectShapeType
1816+
&& $types[$i] instanceof HasPropertyType
1817+
&& !$types[$j]->hasInstanceProperty($types[$i]->getPropertyName())->no()
1818+
) {
1819+
$types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName());
1820+
array_splice($types, $i--, 1);
1821+
$typesCount--;
1822+
continue 2;
1823+
}
1824+
}
1825+
}
1826+
}
1827+
17881828
// transform IntegerType & ConstantIntegerType to ConstantIntegerType
17891829
// transform Child & Parent to Child
17901830
// transform Object & ~null to Object
@@ -1949,20 +1989,6 @@ public static function doIntersect(Type ...$types): Type
19491989
continue 2;
19501990
}
19511991

1952-
if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) {
1953-
$types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName());
1954-
array_splice($types, $j--, 1);
1955-
$typesCount--;
1956-
continue;
1957-
}
1958-
1959-
if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) {
1960-
$types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName());
1961-
array_splice($types, $i--, 1);
1962-
$typesCount--;
1963-
continue 2;
1964-
}
1965-
19661992
$constArrayIsI = $types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType);
19671993
$constArrayIsJ = $types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType);
19681994
if ($constArrayIsI || $constArrayIsJ) {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bug15047;
4+
5+
use stdClass;
6+
use function PHPStan\Testing\assertType;
7+
8+
abstract class AbstractJsonRepresentation
9+
{
10+
11+
/**
12+
* @param stdClass $data
13+
*/
14+
abstract protected static function fromObjectInternal(stdClass $data): self;
15+
16+
}
17+
18+
final class MissalYearLimits extends AbstractJsonRepresentation
19+
{
20+
21+
private ?int $untilYear;
22+
23+
private function __construct(?int $untilYear)
24+
{
25+
$this->untilYear = $untilYear;
26+
}
27+
28+
/**
29+
* @param stdClass&object{since_year:int,until_year?:int} $data
30+
*/
31+
protected static function fromObjectInternal(stdClass $data): self
32+
{
33+
assertType('object{since_year: int, until_year?: int}&stdClass', $data);
34+
assertType('int|null', $data->until_year ?? null);
35+
assertType('int', $data->since_year);
36+
37+
if (isset($data->until_year)) {
38+
assertType('object{since_year: int, until_year: int}&stdClass', $data);
39+
assertType('int', $data->until_year);
40+
}
41+
42+
return new self($data->until_year ?? null);
43+
}
44+
45+
}
46+
47+
/**
48+
* The member order of the intersection must not change the result: whether the object shape or
49+
* stdClass is written first, isset()/?? narrowing resolves the optional key to its declared type.
50+
*
51+
* @param stdClass&object{u?:int} $stdFirst
52+
* @param object{u?:int}&stdClass $shapeFirst
53+
*/
54+
function orderIndependent($stdFirst, $shapeFirst): void
55+
{
56+
assertType('int|null', $stdFirst->u ?? null);
57+
assertType('int|null', $shapeFirst->u ?? null);
58+
}

0 commit comments

Comments
 (0)