From 6248bd9a65afe157f5a8fee19a002601650380d0 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:42:01 +0000 Subject: [PATCH] Fix generic callback return type on union of generic objects - When calling a method with template parameters on a union type (e.g., Collection|Collection), resolve return types for each union member separately instead of combining variants first - The root cause was that combineAcceptors merged variants from different generic instantiations, losing proper template type resolution for callable return types - Excludes TemplateUnionType from decomposition to preserve template type wrapping - Updated static-late-binding test assertion to match now-correct result - New regression test in tests/PHPStan/Analyser/nsrt/bug-14203.php Closes https://github.com/phpstan/phpstan/issues/14203 --- .../Helper/MethodCallReturnTypeHelper.php | 17 ++++ tests/PHPStan/Analyser/nsrt/bug-14203.php | 90 +++++++++++++++++++ .../Analyser/nsrt/static-late-binding.php | 2 +- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14203.php diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 0e9bc9149e..3c53961c80 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -9,8 +9,10 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use function count; #[AutowiredService] @@ -35,6 +37,21 @@ public function methodCallReturnType( return null; } + if ($typeWithMethod instanceof UnionType && !$typeWithMethod instanceof TemplateType) { + $memberTypes = []; + foreach ($typeWithMethod->getTypes() as $memberType) { + $memberResult = $this->methodCallReturnType($scope, $memberType, $methodName, $methodCall); + if ($memberResult === null) { + continue; + } + $memberTypes[] = $memberResult; + } + if (count($memberTypes) > 0) { + return TypeCombinator::union(...$memberTypes); + } + return null; + } + $methodReflection = $typeWithMethod->getMethod($methodName, $scope); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, diff --git a/tests/PHPStan/Analyser/nsrt/bug-14203.php b/tests/PHPStan/Analyser/nsrt/bug-14203.php new file mode 100644 index 0000000000..d35577f07a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14203.php @@ -0,0 +1,90 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14203; + +use function PHPStan\Testing\assertType; + +/** + * @template TKey of array-key + * @template TValue + */ +class Collection +{ + /** + * Create a new collection. + * + * @param array $items + */ + final public function __construct(protected $items = []) + { + } + + /** + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return static + */ + public function map(callable $callback) + { + $newItems = []; + + foreach ($this->items as $key => $value) { + $newItems[$key] = $callback($value, $key); + } + + return new static($newItems); + } +} + +class SpecificA { + public function __construct( + public readonly int $valueA, + public readonly string $someSharedValue, + ) {} +} + +class SpecificB { + public function __construct( + public readonly int $valueB, + public readonly string $someSharedValue, + ) {} +} + +class MyDTO { + public function __construct( + public readonly string $thatSharedValue, + ) {} +} + +function works(): void { + $myCollection = new Collection([new SpecificA(1, 'A'), new SpecificB(2, 'B')]); + + $result = $myCollection->map(static fn (SpecificA|SpecificB $specific): MyDTO => new MyDTO($specific->someSharedValue)); + assertType('Bug14203\Collection', $result); +} + +/** + * @return Collection + */ +function getA(): Collection { + return new Collection([new SpecificA(1, 'A')]); +} + +/** + * @return Collection + */ +function getB(): Collection { + return new Collection([new SpecificB(2, 'B')]); +} + +function breaks(): void { + $myCollection = random_int(0, 1) === 0 + ? getA() + : getB(); + + $result = $myCollection->map(static fn (SpecificA|SpecificB $specific): MyDTO => new MyDTO($specific->someSharedValue)); + assertType('Bug14203\Collection', $result); +} diff --git a/tests/PHPStan/Analyser/nsrt/static-late-binding.php b/tests/PHPStan/Analyser/nsrt/static-late-binding.php index 0493475f76..95bbc31067 100644 --- a/tests/PHPStan/Analyser/nsrt/static-late-binding.php +++ b/tests/PHPStan/Analyser/nsrt/static-late-binding.php @@ -85,7 +85,7 @@ public function foo(): void assertType('static(StaticLateBinding\B)', parent::retStatic()); assertType('static(StaticLateBinding\B)', $this->retStatic()); assertType('bool', X::retStatic()); - assertType('bool|StaticLateBinding\A|StaticLateBinding\X', $clUnioned::retStatic()); // should be bool|StaticLateBinding\A https://github.com/phpstan/phpstan/issues/11687 + assertType('bool|StaticLateBinding\A', $clUnioned::retStatic()); assertType('StaticLateBinding\A', A::retStatic(...)()); assertType('StaticLateBinding\B', B::retStatic(...)());