Skip to content

Fix phpstan/phpstan#13227: Intersections using object shapes#5152

Open
phpstan-bot wants to merge 7 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-mndey5a
Open

Fix phpstan/phpstan#13227: Intersections using object shapes#5152
phpstan-bot wants to merge 7 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-mndey5a

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Object shapes (object{ a: int }) could not be intersected with each other, causing @phpstan-type aliases like Type1 & object{ b: int } to report "Type alias contains unresolvable type." This PR adds support for merging object shapes in intersections.

Changes

  • Added ObjectShapeType intersection merging in src/Type/TypeCombinator.php (placed before isSuperTypeOf checks to handle all cases)
  • Properties from both shapes are combined; overlapping properties have their types intersected
  • If an overlapping property's intersection results in NeverType, the entire intersection returns NeverType
  • A property is optional in the result only if it's optional in both input shapes
  • Properties are sorted by key (ksort) for deterministic output regardless of operand order
  • Updated tests/PHPStan/Type/TypeCombinatorTest.php to expect merged object shape instead of NeverType for disjoint-property intersections
  • Updated phpstan-baseline.neon to reflect additional instanceof ObjectShapeType usages

Root cause

TypeCombinator::intersect() had no special handling for two ObjectShapeType instances. When two object shapes with different properties were intersected (e.g., object{a: int} & object{b: int}), ObjectShapeType::isSuperTypeOf() returned no because each shape was missing properties from the other. This caused TypeCombinator to return an implicit NeverType, which the type alias validator flagged as unresolvable.

Test

Added tests/PHPStan/Analyser/nsrt/bug-13227.php with:

  • Type1 & object{ b: int } where Type1 is object{ a: int } — verifies merge into object{a: int, b: int}
  • object{ a: int, b?: string } & object{ b: string, c?: int } — verifies optional property handling (b becomes required, c stays optional)

Fixes phpstan/phpstan#13227

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Need to be clarified first
phpstan/phpstan#13227 (comment)

@VincentLanglet VincentLanglet self-assigned this Mar 15, 2026
- 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
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-mndey5a branch from b073731 to bc8479f Compare March 30, 2026 13:13
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Add test in TypeCombinatorTest 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}
  • object{foo: int}&object{foo?: string}

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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The make tests background task also completed successfully. All done — the commit with the 5 new test cases has been pushed.

@VincentLanglet VincentLanglet requested a review from staabm March 30, 2026 13:58
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

should have a review from ondrej

@VincentLanglet
Copy link
Copy Markdown
Contributor

should have a review from ondrej

I considered ok given the answer he gave on slack
image

I just added tons of tests

(and he can still review after merge)

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Mar 30, 2026

I see 👍

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

Three more things I'd like you to try:

  1. Try to make each if/else block empty in the src/ diff change. Each one of them should make at least one TypeCombinatorTest fail.
  2. I'd like to see what ObjectShapeType answers in isSuperTypeOf about other instances. Typically I test isSuperTypeOf implementations/changes through TypeCombinatorTest, but here you're hardcoding behaviour into TypeCombinator, so I'd like to see what happens in isSuperTypeOf separately. I'll explain it below.
  3. You added a bunch of dataIntersect tests. What happens in dataUnion? What would make sense to happen?

So, about 2). For union, the rule is - type wins in normalization if it says isSuperTypeOf yes() about the other type. So mixed|int results in mixed.

For intersection, the rule is - type wins, if it says isSuperTypeOf maybe() about the other type (and the other type says yes()). So mixed&int results in int.

If isSuperTypeOf() answers no(), the result of intersection is NeverType.

I'd like you to look at the tests you wrote and verify that isSuperTypeOf answers according to the results you expect from TypeCombinator. Of course in some cases you're combining properties from two shapes into one shape so you can't say which shape "wins" in "isSuperTypeOf". In that case it's okay to return maybe() from both sides.

phpstan-bot and others added 3 commits March 30, 2026 14:48
…Type 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 <noreply@anthropic.com>
…, 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 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

If intersection of Two objectShapes works, the Intersection of

new ObjectShapeType(['foo' => new IntegerType()], []),
new HasPropertyType('bar'),

should not gives NEVER but object{foo: int, bar: mixed}

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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The background make phpstan task also completed successfully with no errors. Everything is pushed.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

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.

4 participants