From 5a56065cc1a88c706cd59228d28e18a41b2aad84 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Tue, 1 Sep 2026 08:56:27 -0500 Subject: [PATCH] Resolve template defaults referencing other templates in composite types `ClassReflection::typeMapFromList()` only substituted a sibling template reference when the default was exactly a bare `TemplateType`. So `@template TResult = TRelated` resolved correctly, but a default that merely contained the reference - `= TRelated|null`, `= list`, or an array shape - kept the unresolved template and leaked it into inferred types. Traverse the type instead, substituting any nested reference to a template of the same class. Bounds are deliberately not descended into, because they may be self-referential (`@template T of Foo`) and traversing them expands without bound. The substitution has to apply to explicitly passed arguments as well as to `$tag->getDefault()`, because TypeNodeResolver already pads missing arguments with the raw defaults before this code runs. --- src/Reflection/ClassReflection.php | 21 ++++++- ...emplate-default-referring-other-nested.php | 60 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/template-default-referring-other-nested.php diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index de07fcf251..82e426e3b2 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -55,6 +55,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeAlias; use PHPStan\Type\TypehintHelper; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\VerbosityLevel; use ReflectionClass as CoreReflectionClass; use ReflectionException; @@ -1861,12 +1862,26 @@ public function typeMapFromList(array $types): TemplateTypeMap $className = $this->getName(); foreach ($resolvedPhpDoc->getTemplateTags() as $tag) { $type = $types[$i] ?? $tag->getDefault() ?? $tag->getBound(); - if ($type instanceof TemplateType && $type->getScope()->getClassName() === $className) { + // A template default may reference other template types of the same + // class, possibly nested inside a composite type like `TValue|null`. + // Never descend into a TemplateType's bound - bounds may be + // self-referential (`@template T of Foo`). + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($map, $className): Type { + if (!$type instanceof TemplateType) { + return $traverse($type); + } + + if ($type->getScope()->getClassName() !== $className) { + return $type; + } + $resolved = $map[$type->getName()] ?? null; if ($resolved !== null && !$resolved instanceof TemplateType) { - $type = $resolved; + return $resolved; } - } + + return $type; + }); $map[$tag->getName()] = $type; $i++; } diff --git a/tests/PHPStan/Analyser/nsrt/template-default-referring-other-nested.php b/tests/PHPStan/Analyser/nsrt/template-default-referring-other-nested.php new file mode 100644 index 0000000000..2391faae1a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/template-default-referring-other-nested.php @@ -0,0 +1,60 @@ + + * @template TShape of array = array{related: TRelated, declaring: TDeclaring} + */ +interface Relation +{ + + /** @return TBare */ + public function bare(): mixed; + + /** @return TResult */ + public function getResults(): mixed; + + /** @return TList */ + public function all(): array; + + /** @return TShape */ + public function shape(): array; + +} + +/** + * @param Relation $defaulted + * @param Relation $explicit + */ +function test(Relation $defaulted, Relation $explicit): void +{ + assertType('TemplateDefaultReferringOtherNested\User', $defaulted->bare()); + assertType('TemplateDefaultReferringOtherNested\User|null', $defaulted->getResults()); + assertType('list', $defaulted->all()); + assertType('array{related: TemplateDefaultReferringOtherNested\User, declaring: TemplateDefaultReferringOtherNested\Post}', $defaulted->shape()); + + assertType('TemplateDefaultReferringOtherNested\User', $explicit->bare()); + assertType('TemplateDefaultReferringOtherNested\User', $explicit->getResults()); +}