Skip to content

Commit b2554dd

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). Resolution logic extracted to TemplateTypeHelper::resolveTemplateTypesInType. Co-Authored-By: Claude Code
1 parent c381cc0 commit b2554dd

File tree

4 files changed

+193
-5
lines changed

4 files changed

+193
-5
lines changed

src/PhpDoc/TypeNodeResolver.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
use PHPStan\Type\Generic\GenericStaticType;
7777
use PHPStan\Type\Generic\TemplateType;
7878
use PHPStan\Type\Generic\TemplateTypeFactory;
79+
use PHPStan\Type\Generic\TemplateTypeHelper;
7980
use PHPStan\Type\Generic\TemplateTypeMap;
8081
use PHPStan\Type\Generic\TemplateTypeScope;
8182
use PHPStan\Type\Generic\TemplateTypeVariance;
@@ -111,6 +112,7 @@
111112
use PHPStan\Type\VoidType;
112113
use Traversable;
113114
use function array_key_exists;
115+
use function array_keys;
114116
use function array_map;
115117
use function array_values;
116118
use function count;
@@ -846,14 +848,34 @@ static function (string $variance): TemplateTypeVariance {
846848
$classReflection = $this->getReflectionProvider()->getClass($mainTypeClassName);
847849
if ($classReflection->isGeneric()) {
848850
$templateTypes = array_values($classReflection->getTemplateTypeMap()->getTypes());
849-
for ($i = count($genericTypes), $templateTypesCount = count($templateTypes); $i < $templateTypesCount; $i++) {
851+
$providedCount = count($genericTypes);
852+
for ($i = $providedCount, $templateTypesCount = count($templateTypes); $i < $templateTypesCount; $i++) {
850853
$templateType = $templateTypes[$i];
851854
if (!$templateType instanceof TemplateType || $templateType->getDefault() === null) {
852855
continue;
853856
}
854857
$genericTypes[] = $templateType->getDefault();
855858
}
856859

860+
if (count($genericTypes) > $providedCount) {
861+
$templateTypeNames = array_keys($classReflection->getTemplateTypeMap()->getTypes());
862+
$resolveMap = [];
863+
for ($j = 0; $j < count($genericTypes); $j++) {
864+
if (!isset($templateTypeNames[$j])) {
865+
continue;
866+
}
867+
$resolveMap[$templateTypeNames[$j]] = $genericTypes[$j];
868+
}
869+
870+
for ($i = $providedCount; $i < count($genericTypes); $i++) {
871+
$genericTypes[$i] = TemplateTypeHelper::resolveTemplateTypesInType($genericTypes[$i], $resolveMap, $mainTypeClassName);
872+
if (!isset($templateTypeNames[$i])) {
873+
continue;
874+
}
875+
$resolveMap[$templateTypeNames[$i]] = $genericTypes[$i];
876+
}
877+
}
878+
857879
if (in_array($mainTypeClassName, [
858880
Traversable::class,
859881
IteratorAggregate::class,

src/Reflection/ClassReflection.php

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,11 +1736,27 @@ public function typeMapFromList(array $types): TemplateTypeMap
17361736

17371737
$map = [];
17381738
$i = 0;
1739+
$defaultKeys = [];
17391740
foreach ($resolvedPhpDoc->getTemplateTags() as $tag) {
1740-
$map[$tag->getName()] = $types[$i] ?? $tag->getDefault() ?? $tag->getBound();
1741+
if (isset($types[$i])) {
1742+
$map[$tag->getName()] = $types[$i];
1743+
} else {
1744+
$default = $tag->getDefault() ?? $tag->getBound();
1745+
$map[$tag->getName()] = $default;
1746+
if ($default->hasTemplateOrLateResolvableType()) {
1747+
$defaultKeys[] = $tag->getName();
1748+
}
1749+
}
17411750
$i++;
17421751
}
17431752

1753+
if ($defaultKeys !== []) {
1754+
$className = $this->getName();
1755+
foreach ($defaultKeys as $key) {
1756+
$map[$key] = TemplateTypeHelper::resolveTemplateTypesInType($map[$key], $map, $className);
1757+
}
1758+
}
1759+
17441760
return new TemplateTypeMap($map);
17451761
}
17461762

@@ -1772,12 +1788,29 @@ public function typeMapToList(TemplateTypeMap $typeMap): array
17721788
return [];
17731789
}
17741790

1775-
$list = [];
1791+
$resolvedMap = [];
1792+
$defaultKeys = [];
17761793
foreach ($resolvedPhpDoc->getTemplateTags() as $tag) {
1777-
$list[] = $typeMap->getType($tag->getName()) ?? $tag->getDefault() ?? $tag->getBound();
1794+
$type = $typeMap->getType($tag->getName());
1795+
if ($type !== null) {
1796+
$resolvedMap[$tag->getName()] = $type;
1797+
} else {
1798+
$default = $tag->getDefault() ?? $tag->getBound();
1799+
$resolvedMap[$tag->getName()] = $default;
1800+
if ($default->hasTemplateOrLateResolvableType()) {
1801+
$defaultKeys[] = $tag->getName();
1802+
}
1803+
}
17781804
}
17791805

1780-
return $list;
1806+
if ($defaultKeys !== []) {
1807+
$className = $this->getName();
1808+
foreach ($defaultKeys as $key) {
1809+
$resolvedMap[$key] = TemplateTypeHelper::resolveTemplateTypesInType($resolvedMap[$key], $resolvedMap, $className);
1810+
}
1811+
}
1812+
1813+
return array_values($resolvedMap);
17811814
}
17821815

17831816
/** @return list<TemplateTypeVariance> */

src/Type/Generic/TemplateTypeHelper.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,22 @@ public static function resolveToBounds(Type $type): Type
9494
});
9595
}
9696

97+
/**
98+
* Resolves template type references within a type using a concrete type map.
99+
* Only resolves template types whose scope matches the given class name.
100+
*
101+
* @param array<string, Type> $resolveMap Map of template name to concrete type
102+
*/
103+
public static function resolveTemplateTypesInType(Type $type, array $resolveMap, string $className): Type
104+
{
105+
return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($resolveMap, $className): Type {
106+
if ($type instanceof TemplateType && $type->getScope()->getClassName() === $className && isset($resolveMap[$type->getName()])) {
107+
return $resolveMap[$type->getName()];
108+
}
109+
return $traverse($type);
110+
});
111+
}
112+
97113
/**
98114
* @template T of Type
99115
* @param T $type
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php // lint >= 8.1
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)