Skip to content

Fix parameter type intersection for union/intersection method types#4936

Open
ondrejmirtes wants to merge 4 commits into2.1.xfrom
claude/merge-type-assertions-t3T6k
Open

Fix parameter type intersection for union/intersection method types#4936
ondrejmirtes wants to merge 4 commits into2.1.xfrom
claude/merge-type-assertions-t3T6k

Conversation

@ondrejmirtes
Copy link
Member

Summary

This PR fixes parameter type handling for union and intersection types by properly intersecting parameter types instead of unioning them. When a method is called on a union or intersection type, the argument must be valid for ALL possible methods, not just one.

Key Changes

  • UnionTypeMethodReflection:

    • Added caching of computed variants via $cachedVariants
    • Implemented proper parameter type intersection: instead of using combineAcceptors which unions types, now explicitly intersects parameter types across all methods
    • Fixed getSelfOutType() to union the self-out types from all methods instead of returning null
    • Fixed getAttributes() to return only attributes present in all methods (intersection semantics)
    • Fixed getResolvedPhpDoc() to return null instead of just the first method's doc
  • IntersectionTypeMethodReflection:

    • Simplified getVariants() to return a single combined variant instead of mapping over the first method's variants
    • Fixed getSelfOutType() to intersect the self-out types from all methods instead of returning null
    • Fixed getAttributes() to return attributes from all methods (union semantics)
    • Fixed getResolvedPhpDoc() to return null instead of just the first method's doc
  • UnionTypePropertyReflection and IntersectionTypePropertyReflection:

    • Fixed getAttributes() to properly handle attributes across multiple properties using intersection (union) semantics

Implementation Details

The core fix addresses issue #9664 where a union type Entity1|Entity2 with methods setFoo(string) and setFoo(?string) should intersect the parameter types to string (the common type), not union them. This ensures type safety: an argument must satisfy ALL possible method signatures.

For union types: parameter types are intersected (stricter requirement)
For intersection types: parameter types are unioned (more flexible requirement)

Added comprehensive test cases in union-intersection-method-variants.php and bug-9664.php to verify the behavior.

https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ

Previously, getSelfOutType(), getAttributes(), and getResolvedPhpDoc()
on Union/IntersectionType{Method,Property}Reflection only used the first
member ($this->methods[0] / $this->properties[0]), losing information
from other members. This applies the same merging pattern from PR #4920
(which fixed assertion merging) to these other metadata methods:

- getSelfOutType(): Union self-out types for union types, intersect for
  intersection types. Returns null if any member lacks a self-out type.
- getAttributes(): For union types, only keep attributes present in ALL
  members (intersection semantics). For intersection types, collect
  attributes from ANY member (union semantics), deduplicating by name.
- getResolvedPhpDoc(): Return null instead of arbitrarily returning the
  first member's PHPDoc block, which could be misleading.

https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ
…rom all methods

Previously, getVariants() used only $this->methods[0]->getVariants()
for parameter structure, ignoring parameters from other methods in the
intersection. This caused false positives when calling methods on
intersection types where the member interfaces have different parameter
types or parameter counts.

For an intersection type A&B, the object satisfies both contracts, so:
- Parameters should be UNIONED (the implementation handles both)
- Return types should be INTERSECTED (must satisfy both)

The fix uses ParametersAcceptorSelector::combineAcceptors() to properly
merge parameter types and counts across all methods, then overrides the
return types with the intersection (which combineAcceptors would union).

Example: given AcceptsInt&AcceptsString where AcceptsInt::process(int)
and AcceptsString::process(string), the combined method now correctly
accepts int|string instead of only int.

https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ
Union type method calls union parameter types instead of intersecting
them. Entity1::setFoo(string) | Entity2::setFoo(?string) combined
becomes setFoo(string|null), so passing null is not flagged.

The sound behavior would intersect params to string, rejecting null.
This is tracked in the test with a comment noting the open issue.

https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ
…ypes

For a union type A|B, we don't know which runtime type the object is,
so arguments must be valid for ALL possible methods. This means
parameter types should be intersected, not unioned.

Previously, combineAcceptors() unioned parameter types, making
string|null the combined type for string vs ?string — allowing null
through when Entity1::setFoo(string) doesn't accept it.

Now parameter types are intersected across methods:
- string & (string|null) = string — correctly rejects null
- int & string = never — NeverType::accepts() returns yes (bottom type
  semantics: unreachable code), so no false positives

Performance: added a fast-path that skips intersection when all methods
come from the same declaring class (e.g. 250-case enum unions), plus
result caching on the instance.

Fixes phpstan/phpstan#9664

https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ
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.

2 participants