From 4281e33d157c8f9d4cb1fb7c7a469e06ecdcbaa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Thu, 26 Mar 2026 13:00:45 +0100 Subject: [PATCH] Resolve generic parameter defaults that reference other template parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. The root cause is that resolveTemplateTypes returns resolved values without re-invoking the callback, so transitive TemplateType references in the standins map are never resolved. Fixed in ClassReflection::typeMapFromList — the single gateway where all type arguments enter the standins map. After building the map, any entry that is a TemplateType referencing another entry in the same class is resolved to that entry's concrete type. Co-Authored-By: Claude Code --- src/Reflection/ClassReflection.php | 11 +- .../nsrt/template-default-referring-other.php | 117 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/template-default-referring-other.php diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index e2a9d32d271..4955c9ca0b8 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -40,6 +40,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; @@ -1736,8 +1737,16 @@ public function typeMapFromList(array $types): TemplateTypeMap $map = []; $i = 0; + $className = $this->getName(); foreach ($resolvedPhpDoc->getTemplateTags() as $tag) { - $map[$tag->getName()] = $types[$i] ?? $tag->getDefault() ?? $tag->getBound(); + $type = $types[$i] ?? $tag->getDefault() ?? $tag->getBound(); + if ($type instanceof TemplateType && $type->getScope()->getClassName() === $className) { + $resolved = $map[$type->getName()] ?? null; + if ($resolved !== null && !$resolved instanceof TemplateType) { + $type = $resolved; + } + } + $map[$tag->getName()] = $type; $i++; } diff --git a/tests/PHPStan/Analyser/nsrt/template-default-referring-other.php b/tests/PHPStan/Analyser/nsrt/template-default-referring-other.php new file mode 100644 index 00000000000..8868c97f75d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/template-default-referring-other.php @@ -0,0 +1,117 @@ += 8.1 + +namespace TemplateDefaultReferringOther; + +use function PHPStan\Testing\assertType; + +class MoneyValue +{ + + public function __construct( + public readonly string $currency, + public readonly int $cents, + ) + { + } + +} + +/** + * @template-contravariant DI + * @template-contravariant EI + * @template-covariant DO of EI = EI + * @template-covariant EO of DI = DI + */ +interface Codec +{ + + /** + * @param DI $data + * @return DO + */ + public function decode(mixed $data): mixed; + + /** + * @param EI $data + * @return EO + */ + public function encode(mixed $data): mixed; + +} + +/** + * @implements Codec< + * array{currency: string, cents: int}, + * MoneyValue, + * > + */ +class MoneyCodec implements Codec +{ + + public function decode(mixed $data): MoneyValue + { + return new MoneyValue($data['currency'], $data['cents']); + } + + public function encode(mixed $data): array + { + return [ + 'currency' => $data->currency, + 'cents' => $data->cents, + ]; + } + +} + +/** + * @implements Codec< + * string, + * \DateTimeInterface, + * \DateTimeImmutable, + * string, + * > + */ +class DateTimeInterfaceCodec implements Codec +{ + + public function decode(mixed $data): \DateTimeImmutable + { + return new \DateTimeImmutable($data); + } + + public function encode(mixed $data): string + { + return $data->format('c'); + } + +} + +/** + * @param Codec $moneyCodec + * @param Codec $dtCodec + */ +function test( + Codec $moneyCodec, + Codec $dtCodec, + string $dtString, + \DateTimeInterface $dtInterface, +): void +{ + assertType('TemplateDefaultReferringOther\MoneyValue', $moneyCodec->decode(['currency' => 'CZK', 'cents' => 123])); + assertType('array{currency: string, cents: int}', $moneyCodec->encode(new MoneyValue('CZK', 100))); + + assertType('DateTimeImmutable', $dtCodec->decode($dtString)); + assertType('string', $dtCodec->encode($dtInterface)); +} + +function testMoneyCodecDirect(MoneyCodec $codec): void +{ + assertType('TemplateDefaultReferringOther\MoneyValue', $codec->decode(['currency' => 'CZK', 'cents' => 123])); + assertType('array{currency: string, cents: int}', $codec->encode(new MoneyValue('CZK', 100))); +} + +function testDateTimeCodecDirect(DateTimeInterfaceCodec $codec): void +{ + assertType('DateTimeImmutable', $codec->decode('2024-01-01')); + assertType('string', $codec->encode(new \DateTimeImmutable())); +}