From bc8479f94845c334be1f3f0cb9b0ddb7f71405a2 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:55:47 +0000 Subject: [PATCH 1/8] Support intersecting object shapes in TypeCombinator - Added ObjectShapeType merging logic in TypeCombinator::intersect() so that two object shapes can be intersected by combining their properties - Properties present in both shapes have their types intersected; if the intersection is never, the whole result is never - A property is optional in the result only if it is optional in both shapes - Updated TypeCombinatorTest expectations and baseline for new instanceof count - New regression test in tests/PHPStan/Analyser/nsrt/bug-13227.php --- phpstan-baseline.neon | 2 +- src/Type/TypeCombinator.php | 34 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13227.php | 29 +++++++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 4 +-- 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13227.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6906e22da9d..b18d053ae7a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1734,7 +1734,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ObjectShapeType is error-prone and deprecated. Use Type::isObject() and Type::hasProperty() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 4 path: src/Type/TypeCombinator.php - diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index e7a0d6bdf52..a7db24b7cbf 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -26,6 +26,7 @@ use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateUnionType; +use function array_filter; use function array_key_exists; use function array_key_first; use function array_merge; @@ -36,6 +37,7 @@ use function get_class; use function in_array; use function is_int; +use function ksort; use function sprintf; use function usort; use const PHP_INT_MAX; @@ -1300,6 +1302,38 @@ public static function intersect(Type ...$types): Type } } + if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof ObjectShapeType) { + $mergedProperties = $types[$i]->getProperties(); + $mergedOptionalProperties = $types[$i]->getOptionalProperties(); + foreach ($types[$j]->getProperties() as $propertyName => $propertyType) { + if (array_key_exists($propertyName, $mergedProperties)) { + $intersectedPropertyType = self::intersect($mergedProperties[$propertyName], $propertyType); + if ($intersectedPropertyType instanceof NeverType) { + return new NeverType(); + } + $mergedProperties[$propertyName] = $intersectedPropertyType; + $isOptionalInI = in_array($propertyName, $mergedOptionalProperties, true); + $isOptionalInJ = in_array($propertyName, $types[$j]->getOptionalProperties(), true); + if ($isOptionalInI && !$isOptionalInJ) { + $mergedOptionalProperties = array_values(array_filter( + $mergedOptionalProperties, + static fn ($p) => $p !== $propertyName, + )); + } + } else { + $mergedProperties[$propertyName] = $propertyType; + if (in_array($propertyName, $types[$j]->getOptionalProperties(), true)) { + $mergedOptionalProperties[] = $propertyName; + } + } + } + ksort($mergedProperties); + $types[$i] = new ObjectShapeType($mergedProperties, $mergedOptionalProperties); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + if ($types[$j] instanceof IterableType) { $isSuperTypeA = $types[$j]->isSuperTypeOfMixed($types[$i]); } else { diff --git a/tests/PHPStan/Analyser/nsrt/bug-13227.php b/tests/PHPStan/Analyser/nsrt/bug-13227.php new file mode 100644 index 00000000000..209bc84d75a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13227.php @@ -0,0 +1,29 @@ + new IntegerType()], []), new ObjectShapeType(['bar' => new StringType()], []), ], - NeverType::class, - '*NEVER*=implicit', + ObjectShapeType::class, + 'object{bar: string, foo: int}', ]; yield [ [ From ef52d294e759b258b82299209de4647537010164 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 13:40:12 +0000 Subject: [PATCH 2/8] Add TypeCombinatorTest cases for object shape intersection edge cases Tests for: object{foo: int}&object{foo?: int}, object{foo: int}&object{foo: int|null}, object{foo: int}&object{foo?: int|null}, object{foo: int}&object{bar?: int}, and object{foo: int}&object{foo?: string}. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Type/TypeCombinatorTest.php | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 135e257305e..27759c9a8c0 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -4619,6 +4619,46 @@ public static function dataIntersect(): iterable ObjectShapeType::class, 'object{bar: string, foo: int}', ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + ], + ObjectShapeType::class, + 'object{bar?: int, foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + ], + NeverType::class, + '*NEVER*=implicit', + ]; yield [ [ new ObjectShapeType(['foo' => new IntegerType()], []), From c26a9991a79405c7f2c807ba8e9dc1e8fb20e641 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 14:48:08 +0000 Subject: [PATCH 3/8] Fix ObjectShapeType::isSuperTypeOf for ObjectShapeType-to-ObjectShapeType comparisons Object shapes are open structural types - not listing a property doesn't mean the runtime object won't have it. When comparing two ObjectShapeTypes, a missing property should return maybe() instead of no() (for required) or yes() via continue (for optional), since the actual object could have the property with any type. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 2 +- src/Type/ObjectShapeType.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b18d053ae7a..e8222242488 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1464,7 +1464,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ObjectShapeType is error-prone and deprecated. Use Type::isObject() and Type::hasProperty() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 3 path: src/Type/ObjectShapeType.php - diff --git a/src/Type/ObjectShapeType.php b/src/Type/ObjectShapeType.php index 9dedcfa1480..495006ce2bd 100644 --- a/src/Type/ObjectShapeType.php +++ b/src/Type/ObjectShapeType.php @@ -297,6 +297,10 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult foreach ($this->properties as $propertyName => $propertyType) { $hasProperty = new IsSuperTypeOfResult($type->hasInstanceProperty((string) $propertyName), []); if ($hasProperty->no()) { + if ($type instanceof self) { + $result = $result->and(IsSuperTypeOfResult::createMaybe()); + continue; + } if (in_array($propertyName, $this->optionalProperties, true)) { continue; } From d41aeb1dba04e89b464b06f8c6a460dacfeb6954 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 14:48:17 +0000 Subject: [PATCH 4/8] Add ObjectShapeType isSuperTypeOf tests, intersection branch coverage, and union tests - Create ObjectShapeTypeTest with isSuperTypeOf tests covering: same types, wider/narrower types, incompatible types, disjoint properties, required vs optional, empty shapes, and subset/superset relationships - Add dataIntersect tests with optional-in-i ordering to cover the isOptionalInI && !isOptionalInJ branch in TypeCombinator - Add dataUnion tests for object shapes: optional absorbs required, wider type absorbs narrower, disjoint properties stay as union, subset absorbs superset Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Type/ObjectShapeTypeTest.php | 162 +++++++++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 64 ++++++++ 2 files changed, 226 insertions(+) create mode 100644 tests/PHPStan/Type/ObjectShapeTypeTest.php diff --git a/tests/PHPStan/Type/ObjectShapeTypeTest.php b/tests/PHPStan/Type/ObjectShapeTypeTest.php new file mode 100644 index 00000000000..93489e5da5b --- /dev/null +++ b/tests/PHPStan/Type/ObjectShapeTypeTest.php @@ -0,0 +1,162 @@ + new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Wider property type is supertype + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new ConstantIntegerType(1)], []), + TrinaryLogic::createYes(), + ]; + + // Narrower property type is maybe supertype + yield [ + new ObjectShapeType(['foo' => new ConstantIntegerType(1)], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Incompatible property types + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], []), + TrinaryLogic::createNo(), + ]; + + // Disjoint properties - object shapes are open types + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new StringType()], []), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ObjectShapeType(['bar' => new StringType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Required vs optional: optional is supertype of required + yield [ + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Required vs optional: required is maybe supertype of optional + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + TrinaryLogic::createMaybe(), + ]; + + // Wider type with required property + yield [ + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Narrower type checking wider + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + TrinaryLogic::createMaybe(), + ]; + + // Optional wider type vs required narrower + yield [ + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Required narrower vs optional wider + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + TrinaryLogic::createMaybe(), + ]; + + // Disjoint with optional property + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Optional property with incompatible types + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + TrinaryLogic::createMaybe(), + ]; + + // Superset has extra required property - maybe because shapes are open + yield [ + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Subset is supertype + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], []), + TrinaryLogic::createYes(), + ]; + + // Empty shape is supertype of any shape + yield [ + new ObjectShapeType([], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Any shape is maybe supertype of empty shape + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType([], []), + TrinaryLogic::createMaybe(), + ]; + } + + /** + * @param TrinaryLogic $expectedResult + */ + #[DataProvider('dataIsSuperTypeOf')] + public function testIsSuperTypeOf(ObjectShapeType $type, Type $otherType, TrinaryLogic $expectedResult): void + { + $actualResult = $type->isSuperTypeOf($otherType); + $this->assertSame( + $expectedResult->describe(), + $actualResult->describe(), + sprintf('%s -> isSuperTypeOf(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 27759c9a8c0..3eec994b1b2 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -2578,6 +2578,54 @@ public static function dataUnion(): iterable UnionType::class, 'object{bar: string}|object{foo: int}', ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + ], + ObjectShapeType::class, + 'object{foo?: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + ], + ObjectShapeType::class, + 'object{foo: int|null}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + ], + ObjectShapeType::class, + 'object{foo?: int|null}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + ], + UnionType::class, + 'object{bar?: int}|object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + ], + UnionType::class, + 'object{foo: int}|object{foo?: string}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; yield [ [ @@ -4659,6 +4707,22 @@ public static function dataIntersect(): iterable NeverType::class, '*NEVER*=implicit', ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + ], + ObjectShapeType::class, + 'object{bar: string, foo: int}', + ]; yield [ [ new ObjectShapeType(['foo' => new IntegerType()], []), From e0c9a7265357f0f9a9910b337920247bff50b92d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Mar 2026 17:07:24 +0200 Subject: [PATCH 5/8] Change --- src/Type/TypeCombinator.php | 11 ++++++++--- tests/PHPStan/Type/TypeCombinatorTest.php | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index a7db24b7cbf..6b9a27c9ef2 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1309,12 +1309,17 @@ public static function intersect(Type ...$types): Type if (array_key_exists($propertyName, $mergedProperties)) { $intersectedPropertyType = self::intersect($mergedProperties[$propertyName], $propertyType); if ($intersectedPropertyType instanceof NeverType) { + if (in_array($propertyName, $types[$j]->getOptionalProperties(), true)) { + continue; + } + return new NeverType(); } $mergedProperties[$propertyName] = $intersectedPropertyType; - $isOptionalInI = in_array($propertyName, $mergedOptionalProperties, true); - $isOptionalInJ = in_array($propertyName, $types[$j]->getOptionalProperties(), true); - if ($isOptionalInI && !$isOptionalInJ) { + if ( + in_array($propertyName, $mergedOptionalProperties, true) + && !in_array($propertyName, $types[$j]->getOptionalProperties(), true) + ) { $mergedOptionalProperties = array_values(array_filter( $mergedOptionalProperties, static fn ($p) => $p !== $propertyName, diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 3eec994b1b2..e4b511935a3 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -4705,7 +4705,7 @@ public static function dataIntersect(): iterable new ObjectShapeType(['foo' => new StringType()], ['foo']), ], NeverType::class, - '*NEVER*=implicit', + 'object{foo: int}', ]; yield [ [ From 28c61b507e15e92d8b58513621196ce481b7bad6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 15:21:28 +0000 Subject: [PATCH 6/8] Support intersecting ObjectShapeType with HasPropertyType When intersecting an object shape with HasPropertyType, if the property doesn't exist in the shape, add it as mixed. If it exists, make it required. This is handled before isSuperTypeOf checks, similar to the ObjectShapeType-ObjectShapeType intersection. Also fix the NeverType check for overlapping properties with incompatible types: only skip if the property is optional in both shapes (not just one). Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeCombinator.php | 49 ++++++++++++++++------- tests/PHPStan/Type/TypeCombinatorTest.php | 6 +-- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 6b9a27c9ef2..296d55e8e82 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1309,7 +1309,10 @@ public static function intersect(Type ...$types): Type if (array_key_exists($propertyName, $mergedProperties)) { $intersectedPropertyType = self::intersect($mergedProperties[$propertyName], $propertyType); if ($intersectedPropertyType instanceof NeverType) { - if (in_array($propertyName, $types[$j]->getOptionalProperties(), true)) { + if ( + in_array($propertyName, $mergedOptionalProperties, true) + && in_array($propertyName, $types[$j]->getOptionalProperties(), true) + ) { continue; } @@ -1339,6 +1342,36 @@ public static function intersect(Type ...$types): Type continue; } + if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) { + $propertyName = $types[$j]->getPropertyName(); + if (!array_key_exists($propertyName, $types[$i]->getProperties())) { + $properties = $types[$i]->getProperties(); + $properties[$propertyName] = new MixedType(); + ksort($properties); + $types[$i] = new ObjectShapeType($properties, $types[$i]->getOptionalProperties()); + } else { + $types[$i] = $types[$i]->makePropertyRequired($propertyName); + } + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) { + $propertyName = $types[$i]->getPropertyName(); + if (!array_key_exists($propertyName, $types[$j]->getProperties())) { + $properties = $types[$j]->getProperties(); + $properties[$propertyName] = new MixedType(); + ksort($properties); + $types[$j] = new ObjectShapeType($properties, $types[$j]->getOptionalProperties()); + } else { + $types[$j] = $types[$j]->makePropertyRequired($propertyName); + } + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + if ($types[$j] instanceof IterableType) { $isSuperTypeA = $types[$j]->isSuperTypeOfMixed($types[$i]); } else { @@ -1449,20 +1482,6 @@ public static function intersect(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; - } - if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { $newArray = ConstantArrayTypeBuilder::createEmpty(); $valueTypes = $types[$i]->getValueTypes(); diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index e4b511935a3..6fbd32c24ea 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -4632,8 +4632,8 @@ public static function dataIntersect(): iterable new ObjectShapeType(['foo' => new IntegerType()], []), new HasPropertyType('bar'), ], - NeverType::class, - '*NEVER*=implicit', + ObjectShapeType::class, + 'object{bar: mixed, foo: int}', ]; yield [ [ @@ -4705,7 +4705,7 @@ public static function dataIntersect(): iterable new ObjectShapeType(['foo' => new StringType()], ['foo']), ], NeverType::class, - 'object{foo: int}', + '*NEVER*=implicit', ]; yield [ [ From 8f4e3bc43d14f1217d7edc09afa7d9238ecd0809 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Mar 2026 17:35:28 +0200 Subject: [PATCH 7/8] Rework --- src/Type/TypeCombinator.php | 5 +++++ tests/PHPStan/Type/TypeCombinatorTest.php | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 296d55e8e82..28f3ec53549 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1313,6 +1313,11 @@ public static function intersect(Type ...$types): Type in_array($propertyName, $mergedOptionalProperties, true) && in_array($propertyName, $types[$j]->getOptionalProperties(), true) ) { + unset($mergedProperties[$propertyName]); + $mergedOptionalProperties = array_values(array_filter( + $mergedOptionalProperties, + static fn ($p) => $p !== $propertyName, + )); continue; } diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 6fbd32c24ea..7e6557a4939 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -4707,6 +4707,14 @@ public static function dataIntersect(): iterable NeverType::class, '*NEVER*=implicit', ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + ], + ObjectShapeType::class, + 'object{}', + ]; yield [ [ new ObjectShapeType(['foo' => new IntegerType()], ['foo']), From cdcb7bdc300d5f1f17d6785b4c09103b6732756c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Mar 2026 21:26:39 +0200 Subject: [PATCH 8/8] Try --- phpstan-baseline.neon | 2 +- src/Type/ObjectShapeType.php | 25 ++++++++----------- ...mpossibleCheckTypeFunctionCallRuleTest.php | 8 +----- tests/PHPStan/Type/ObjectShapeTypeTest.php | 3 --- 4 files changed, 13 insertions(+), 25 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e8222242488..b18d053ae7a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1464,7 +1464,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ObjectShapeType is error-prone and deprecated. Use Type::isObject() and Type::hasProperty() instead.' identifier: phpstanApi.instanceofType - count: 3 + count: 2 path: src/Type/ObjectShapeType.php - diff --git a/src/Type/ObjectShapeType.php b/src/Type/ObjectShapeType.php index 495006ce2bd..78a89a87b59 100644 --- a/src/Type/ObjectShapeType.php +++ b/src/Type/ObjectShapeType.php @@ -10,6 +10,7 @@ use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ClassMemberAccessAnswerer; +use PHPStan\Reflection\Dummy\DummyPropertyReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\MissingPropertyFromReflectionException; use PHPStan\Reflection\Php\UniversalObjectCratesClassReflectionExtension; @@ -113,15 +114,14 @@ public function getUnresolvedPropertyPrototype(string $propertyName, ClassMember public function hasInstanceProperty(string $propertyName): TrinaryLogic { - if (!array_key_exists($propertyName, $this->properties)) { - return TrinaryLogic::createNo(); + if ( + array_key_exists($propertyName, $this->properties) + && !in_array($propertyName, $this->optionalProperties, true) + ) { + return TrinaryLogic::createYes(); } - if (in_array($propertyName, $this->optionalProperties, true)) { - return TrinaryLogic::createMaybe(); - } - - return TrinaryLogic::createYes(); + return TrinaryLogic::createMaybe(); } public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -131,11 +131,12 @@ public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswe public function getUnresolvedInstancePropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection { - if (!array_key_exists($propertyName, $this->properties)) { - throw new ShouldNotHappenException(); + if (array_key_exists($propertyName, $this->properties)) { + $property = new ObjectShapePropertyReflection($propertyName, $this->properties[$propertyName]); + } else { + $property = new DummyPropertyReflection($propertyName); } - $property = new ObjectShapePropertyReflection($propertyName, $this->properties[$propertyName]); return new CallbackUnresolvedPropertyPrototypeReflection( $property, $property->getDeclaringClass(), @@ -297,10 +298,6 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult foreach ($this->properties as $propertyName => $propertyType) { $hasProperty = new IsSuperTypeOfResult($type->hasInstanceProperty((string) $propertyName), []); if ($hasProperty->no()) { - if ($type instanceof self) { - $result = $result->and(IsSuperTypeOfResult::createMaybe()); - continue; - } if (in_array($propertyName, $this->optionalProperties, true)) { continue; } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 69e992139d8..7407ac04aa6 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -721,13 +721,7 @@ public function testReportAlwaysTrueInLastCondition(bool $reportAlwaysTrueInLast public function testObjectShapes(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/property-exists-object-shapes.php'], [ - [ - 'Call to function property_exists() with object{foo: int, bar?: string} and \'baz\' will always evaluate to false.', - 24, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', - ], - ]); + $this->analyse([__DIR__ . '/data/property-exists-object-shapes.php'], []); } /** @return list */ diff --git a/tests/PHPStan/Type/ObjectShapeTypeTest.php b/tests/PHPStan/Type/ObjectShapeTypeTest.php index 93489e5da5b..f66e2a82344 100644 --- a/tests/PHPStan/Type/ObjectShapeTypeTest.php +++ b/tests/PHPStan/Type/ObjectShapeTypeTest.php @@ -145,9 +145,6 @@ public static function dataIsSuperTypeOf(): iterable ]; } - /** - * @param TrinaryLogic $expectedResult - */ #[DataProvider('dataIsSuperTypeOf')] public function testIsSuperTypeOf(ObjectShapeType $type, Type $otherType, TrinaryLogic $expectedResult): void {