Skip to content

Commit 6248bd9

Browse files
committed
Fix generic callback return type on union of generic objects
- When calling a method with template parameters on a union type (e.g., Collection<int, A>|Collection<int, B>), 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 phpstan/phpstan#14203
1 parent cfba43c commit 6248bd9

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
use PHPStan\DependencyInjection\AutowiredService;
1010
use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider;
1111
use PHPStan\Reflection\ParametersAcceptorSelector;
12+
use PHPStan\Type\Generic\TemplateType;
1213
use PHPStan\Type\Type;
1314
use PHPStan\Type\TypeCombinator;
15+
use PHPStan\Type\UnionType;
1416
use function count;
1517

1618
#[AutowiredService]
@@ -35,6 +37,21 @@ public function methodCallReturnType(
3537
return null;
3638
}
3739

40+
if ($typeWithMethod instanceof UnionType && !$typeWithMethod instanceof TemplateType) {
41+
$memberTypes = [];
42+
foreach ($typeWithMethod->getTypes() as $memberType) {
43+
$memberResult = $this->methodCallReturnType($scope, $memberType, $methodName, $methodCall);
44+
if ($memberResult === null) {
45+
continue;
46+
}
47+
$memberTypes[] = $memberResult;
48+
}
49+
if (count($memberTypes) > 0) {
50+
return TypeCombinator::union(...$memberTypes);
51+
}
52+
return null;
53+
}
54+
3855
$methodReflection = $typeWithMethod->getMethod($methodName, $scope);
3956
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
4057
$scope,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14203;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @template TKey of array-key
11+
* @template TValue
12+
*/
13+
class Collection
14+
{
15+
/**
16+
* Create a new collection.
17+
*
18+
* @param array<TKey, TValue> $items
19+
*/
20+
final public function __construct(protected $items = [])
21+
{
22+
}
23+
24+
/**
25+
* @template TMapValue
26+
*
27+
* @param callable(TValue, TKey): TMapValue $callback
28+
* @return static<TKey, TMapValue>
29+
*/
30+
public function map(callable $callback)
31+
{
32+
$newItems = [];
33+
34+
foreach ($this->items as $key => $value) {
35+
$newItems[$key] = $callback($value, $key);
36+
}
37+
38+
return new static($newItems);
39+
}
40+
}
41+
42+
class SpecificA {
43+
public function __construct(
44+
public readonly int $valueA,
45+
public readonly string $someSharedValue,
46+
) {}
47+
}
48+
49+
class SpecificB {
50+
public function __construct(
51+
public readonly int $valueB,
52+
public readonly string $someSharedValue,
53+
) {}
54+
}
55+
56+
class MyDTO {
57+
public function __construct(
58+
public readonly string $thatSharedValue,
59+
) {}
60+
}
61+
62+
function works(): void {
63+
$myCollection = new Collection([new SpecificA(1, 'A'), new SpecificB(2, 'B')]);
64+
65+
$result = $myCollection->map(static fn (SpecificA|SpecificB $specific): MyDTO => new MyDTO($specific->someSharedValue));
66+
assertType('Bug14203\Collection<int, Bug14203\MyDTO>', $result);
67+
}
68+
69+
/**
70+
* @return Collection<int, SpecificA>
71+
*/
72+
function getA(): Collection {
73+
return new Collection([new SpecificA(1, 'A')]);
74+
}
75+
76+
/**
77+
* @return Collection<int, SpecificB>
78+
*/
79+
function getB(): Collection {
80+
return new Collection([new SpecificB(2, 'B')]);
81+
}
82+
83+
function breaks(): void {
84+
$myCollection = random_int(0, 1) === 0
85+
? getA()
86+
: getB();
87+
88+
$result = $myCollection->map(static fn (SpecificA|SpecificB $specific): MyDTO => new MyDTO($specific->someSharedValue));
89+
assertType('Bug14203\Collection<int, Bug14203\MyDTO>', $result);
90+
}

tests/PHPStan/Analyser/nsrt/static-late-binding.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public function foo(): void
8585
assertType('static(StaticLateBinding\B)', parent::retStatic());
8686
assertType('static(StaticLateBinding\B)', $this->retStatic());
8787
assertType('bool', X::retStatic());
88-
assertType('bool|StaticLateBinding\A|StaticLateBinding\X', $clUnioned::retStatic()); // should be bool|StaticLateBinding\A https://github.com/phpstan/phpstan/issues/11687
88+
assertType('bool|StaticLateBinding\A', $clUnioned::retStatic());
8989

9090
assertType('StaticLateBinding\A', A::retStatic(...)());
9191
assertType('StaticLateBinding\B', B::retStatic(...)());

0 commit comments

Comments
 (0)