Skip to content

Commit dd69bbf

Browse files
committed
Resolve generic parameter defaults that reference other template parameters
When a template parameter has a default referencing another template parameter (e.g. `@template DO of EI = EI`), the default was left as an unresolved TemplateType instead of being substituted with the concrete type provided for the referenced parameter. Fixed in TypeNodeResolver (primary path when parsing PHPDoc types) and ClassReflection::typeMapFromList/typeMapToList (defensive fix). Co-Authored-By: Claude Code
1 parent c381cc0 commit dd69bbf

File tree

3 files changed

+190
-5
lines changed

3 files changed

+190
-5
lines changed

src/PhpDoc/TypeNodeResolver.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
use PHPStan\Type\StringType;
103103
use PHPStan\Type\ThisType;
104104
use PHPStan\Type\Type;
105+
use PHPStan\Type\TypeTraverser;
105106
use PHPStan\Type\TypeAliasResolver;
106107
use PHPStan\Type\TypeAliasResolverProvider;
107108
use PHPStan\Type\TypeCombinator;
@@ -846,14 +847,36 @@ static function (string $variance): TemplateTypeVariance {
846847
$classReflection = $this->getReflectionProvider()->getClass($mainTypeClassName);
847848
if ($classReflection->isGeneric()) {
848849
$templateTypes = array_values($classReflection->getTemplateTypeMap()->getTypes());
849-
for ($i = count($genericTypes), $templateTypesCount = count($templateTypes); $i < $templateTypesCount; $i++) {
850+
$providedCount = count($genericTypes);
851+
for ($i = $providedCount, $templateTypesCount = count($templateTypes); $i < $templateTypesCount; $i++) {
850852
$templateType = $templateTypes[$i];
851853
if (!$templateType instanceof TemplateType || $templateType->getDefault() === null) {
852854
continue;
853855
}
854856
$genericTypes[] = $templateType->getDefault();
855857
}
856858

859+
if (count($genericTypes) > $providedCount) {
860+
$templateTypeMap = $classReflection->getTemplateTypeMap();
861+
$resolveMap = [];
862+
$j = 0;
863+
foreach ($templateTypeMap->getTypes() as $name => $type) {
864+
if (isset($genericTypes[$j])) {
865+
$resolveMap[$name] = $genericTypes[$j];
866+
}
867+
$j++;
868+
}
869+
870+
for ($i = $providedCount; $i < count($genericTypes); $i++) {
871+
$genericTypes[$i] = TypeTraverser::map($genericTypes[$i], static function (Type $type, callable $traverse) use ($resolveMap, $mainTypeClassName): Type {
872+
if ($type instanceof TemplateType && $type->getScope()->getClassName() === $mainTypeClassName && isset($resolveMap[$type->getName()])) {
873+
return $resolveMap[$type->getName()];
874+
}
875+
return $traverse($type);
876+
});
877+
}
878+
}
879+
857880
if (in_array($mainTypeClassName, [
858881
Traversable::class,
859882
IteratorAggregate::class,

src/Reflection/ClassReflection.php

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
4949
use PHPStan\Type\Generic\TypeProjectionHelper;
5050
use PHPStan\Type\ObjectType;
51+
use PHPStan\Type\Generic\TemplateType;
5152
use PHPStan\Type\Type;
53+
use PHPStan\Type\TypeTraverser;
5254
use PHPStan\Type\TypeAlias;
5355
use PHPStan\Type\TypehintHelper;
5456
use PHPStan\Type\VerbosityLevel;
@@ -1736,11 +1738,32 @@ public function typeMapFromList(array $types): TemplateTypeMap
17361738

17371739
$map = [];
17381740
$i = 0;
1741+
$needsResolution = false;
17391742
foreach ($resolvedPhpDoc->getTemplateTags() as $tag) {
1740-
$map[$tag->getName()] = $types[$i] ?? $tag->getDefault() ?? $tag->getBound();
1743+
if (isset($types[$i])) {
1744+
$map[$tag->getName()] = $types[$i];
1745+
} else {
1746+
$default = $tag->getDefault() ?? $tag->getBound();
1747+
$map[$tag->getName()] = $default;
1748+
if ($default->hasTemplateOrLateResolvableType()) {
1749+
$needsResolution = true;
1750+
}
1751+
}
17411752
$i++;
17421753
}
17431754

1755+
if ($needsResolution) {
1756+
$className = $this->getName();
1757+
foreach ($map as $name => $type) {
1758+
$map[$name] = TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($map, $className): Type {
1759+
if ($type instanceof TemplateType && $type->getScope()->getClassName() === $className && isset($map[$type->getName()])) {
1760+
return $map[$type->getName()];
1761+
}
1762+
return $traverse($type);
1763+
});
1764+
}
1765+
}
1766+
17441767
return new TemplateTypeMap($map);
17451768
}
17461769

@@ -1772,12 +1795,34 @@ public function typeMapToList(TemplateTypeMap $typeMap): array
17721795
return [];
17731796
}
17741797

1775-
$list = [];
1798+
$resolvedMap = [];
1799+
$needsResolution = false;
17761800
foreach ($resolvedPhpDoc->getTemplateTags() as $tag) {
1777-
$list[] = $typeMap->getType($tag->getName()) ?? $tag->getDefault() ?? $tag->getBound();
1801+
$type = $typeMap->getType($tag->getName());
1802+
if ($type !== null) {
1803+
$resolvedMap[$tag->getName()] = $type;
1804+
} else {
1805+
$default = $tag->getDefault() ?? $tag->getBound();
1806+
$resolvedMap[$tag->getName()] = $default;
1807+
if ($default->hasTemplateOrLateResolvableType()) {
1808+
$needsResolution = true;
1809+
}
1810+
}
17781811
}
17791812

1780-
return $list;
1813+
if ($needsResolution) {
1814+
$className = $this->getName();
1815+
foreach ($resolvedMap as $name => $type) {
1816+
$resolvedMap[$name] = TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($resolvedMap, $className): Type {
1817+
if ($type instanceof TemplateType && $type->getScope()->getClassName() === $className && isset($resolvedMap[$type->getName()])) {
1818+
return $resolvedMap[$type->getName()];
1819+
}
1820+
return $traverse($type);
1821+
});
1822+
}
1823+
}
1824+
1825+
return array_values($resolvedMap);
17811826
}
17821827

17831828
/** @return list<TemplateTypeVariance> */
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php // lint >= 8.0
2+
3+
namespace TemplateDefaultReferringOther;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class MoneyValue
8+
{
9+
10+
public function __construct(
11+
public readonly string $currency,
12+
public readonly int $cents,
13+
)
14+
{
15+
}
16+
17+
}
18+
19+
/**
20+
* @template DI
21+
* @template EI
22+
* @template DO of EI = EI
23+
* @template EO of DI = DI
24+
*/
25+
interface Codec
26+
{
27+
28+
/**
29+
* @param DI $data
30+
* @return DO
31+
*/
32+
public function decode(mixed $data): mixed;
33+
34+
/**
35+
* @param EI $data
36+
* @return EO
37+
*/
38+
public function encode(mixed $data): mixed;
39+
40+
}
41+
42+
/**
43+
* @implements Codec<
44+
* array{currency: string, cents: int},
45+
* MoneyValue,
46+
* >
47+
*/
48+
class MoneyCodec implements Codec
49+
{
50+
51+
public function decode(mixed $data): MoneyValue
52+
{
53+
return new MoneyValue($data['currency'], $data['cents']);
54+
}
55+
56+
public function encode(mixed $data): array
57+
{
58+
return [
59+
'currency' => $data->currency,
60+
'cents' => $data->cents,
61+
];
62+
}
63+
64+
}
65+
66+
/**
67+
* @implements Codec<
68+
* string,
69+
* \DateTimeInterface,
70+
* \DateTimeImmutable,
71+
* string,
72+
* >
73+
*/
74+
class DateTimeInterfaceCodec implements Codec
75+
{
76+
77+
public function decode(mixed $data): \DateTimeImmutable
78+
{
79+
return new \DateTimeImmutable($data);
80+
}
81+
82+
public function encode(mixed $data): string
83+
{
84+
return $data->format('c');
85+
}
86+
87+
}
88+
89+
/**
90+
* @param Codec<array{currency: string, cents: int}, MoneyValue> $moneyCodec
91+
* @param Codec<string, \DateTimeInterface, \DateTimeImmutable, string> $dtCodec
92+
*/
93+
function test(
94+
Codec $moneyCodec,
95+
Codec $dtCodec,
96+
string $dtString,
97+
\DateTimeInterface $dtInterface,
98+
): void
99+
{
100+
assertType('TemplateDefaultReferringOther\MoneyValue', $moneyCodec->decode(['currency' => 'CZK', 'cents' => 123]));
101+
assertType('array{currency: string, cents: int}', $moneyCodec->encode(new MoneyValue('CZK', 100)));
102+
103+
assertType('DateTimeImmutable', $dtCodec->decode($dtString));
104+
assertType('string', $dtCodec->encode($dtInterface));
105+
}
106+
107+
function testMoneyCodecDirect(MoneyCodec $codec): void
108+
{
109+
assertType('TemplateDefaultReferringOther\MoneyValue', $codec->decode(['currency' => 'CZK', 'cents' => 123]));
110+
assertType('array{currency: string, cents: int}', $codec->encode(new MoneyValue('CZK', 100)));
111+
}
112+
113+
function testDateTimeCodecDirect(DateTimeInterfaceCodec $codec): void
114+
{
115+
assertType('DateTimeImmutable', $codec->decode('2024-01-01'));
116+
assertType('string', $codec->encode(new \DateTimeImmutable()));
117+
}

0 commit comments

Comments
 (0)