From 345c854c3a122573f50e93d0e32800eafddaf964 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:42:15 +0000 Subject: [PATCH] Fix class-string template resolution with generic args - When a template type K (bound to IFoo) is used as K in a return type, PHPStan now correctly resolves to the concrete class (e.g., Foo) instead of the bound interface (IFoo) - Added TemplateAppliedGenericObjectType to distinguish usage-site generic args (K) from template declarations with generic bounds (K of IFoo) - Resolution in ResolvedFunctionVariantWithOriginal replaces the bound's class name with the resolved template's class name after inner type traversal - New regression test in tests/PHPStan/Analyser/nsrt/bug-4971.php Closes https://github.com/phpstan/phpstan/issues/4971 --- src/PhpDoc/TypeNodeResolver.php | 24 +++++++ .../ResolvedFunctionVariantWithOriginal.php | 20 +++++- .../TemplateAppliedGenericObjectType.php | 51 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-4971.php | 62 +++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/Type/Generic/TemplateAppliedGenericObjectType.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-4971.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 8af74bf926..14005c813c 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -74,6 +74,7 @@ use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\GenericStaticType; +use PHPStan\Type\Generic\TemplateAppliedGenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeMap; @@ -838,6 +839,29 @@ static function (string $variance): TemplateTypeVariance { } $mainTypeClassName = $mainTypeObjectClassNames[0] ?? null; + if ($mainType instanceof TemplateType && $mainTypeClassName !== null) { + if ($this->getReflectionProvider()->hasClass($mainTypeClassName)) { + $classReflection = $this->getReflectionProvider()->getClass($mainTypeClassName); + if ($classReflection->isGeneric()) { + $templateTypes = array_values($classReflection->getTemplateTypeMap()->getTypes()); + for ($i = count($genericTypes), $templateTypesCount = count($templateTypes); $i < $templateTypesCount; $i++) { + $templateType = $templateTypes[$i]; + if (!$templateType instanceof TemplateType || $templateType->getDefault() === null) { + continue; + } + $genericTypes[] = $templateType->getDefault(); + } + } + } + + return new TemplateAppliedGenericObjectType( + $mainType->getName(), + $mainTypeClassName, + $genericTypes, + variances: array_values($variances), + ); + } + if ($mainTypeClassName !== null) { if (!$this->getReflectionProvider()->hasClass($mainTypeClassName)) { return new GenericObjectType($mainTypeClassName, $genericTypes, variances: $variances); diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 21108d658e..3fca7f10df 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -7,6 +7,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\GenericStaticType; +use PHPStan\Type\Generic\TemplateAppliedGenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; @@ -18,6 +19,7 @@ use PHPStan\Type\TypeUtils; use function array_key_exists; use function array_map; +use function count; final class ResolvedFunctionVariantWithOriginal implements ResolvedFunctionVariant { @@ -242,7 +244,23 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance return $newType; } - return $traverse($type); + $result = $traverse($type); + + if ($result instanceof TemplateAppliedGenericObjectType) { + $resolvedType = $this->resolvedTemplateTypeMap->getType($result->getTemplateName()); + if ($resolvedType !== null && !$resolvedType instanceof ErrorType) { + $resolvedClassNames = $resolvedType->getObjectClassNames(); + if (count($resolvedClassNames) === 1) { + return new GenericObjectType( + $resolvedClassNames[0], + $result->getTypes(), + variances: $result->getVariances(), + ); + } + } + } + + return $result; }; return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type { diff --git a/src/Type/Generic/TemplateAppliedGenericObjectType.php b/src/Type/Generic/TemplateAppliedGenericObjectType.php new file mode 100644 index 0000000000..d97e156575 --- /dev/null +++ b/src/Type/Generic/TemplateAppliedGenericObjectType.php @@ -0,0 +1,51 @@ + where @template K of IFoo. + * + * This is distinct from TemplateGenericObjectType which represents a template + * declared with a generic bound (@template K of IFoo). + */ +final class TemplateAppliedGenericObjectType extends GenericObjectType +{ + + /** + * @param non-empty-string $templateName + * @param list $types + * @param list $variances + */ + public function __construct( + private string $templateName, + string $className, + array $types, + ?Type $subtractedType = null, + array $variances = [], + ) + { + parent::__construct($className, $types, $subtractedType, variances: $variances); + } + + /** @return non-empty-string */ + public function getTemplateName(): string + { + return $this->templateName; + } + + protected function recreate(string $className, array $types, ?Type $subtractedType, array $variances = []): GenericObjectType + { + return new self( + $this->templateName, + $className, + array_values($types), + $subtractedType, + array_values($variances), + ); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-4971.php b/tests/PHPStan/Analyser/nsrt/bug-4971.php new file mode 100644 index 0000000000..90e14487c1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4971.php @@ -0,0 +1,62 @@ + + */ +class Foo implements IFoo +{ + /** @var T */ + private $v; // @phpstan-ignore property.uninitializedReadonly + + /** + * @param T $v + */ + public function __construct($v) + { + $this->v = $v; + } +} + +/** + * @template T + * @template K of IFoo + * @param T $v + * @param class-string $class + * @return K + */ +function make1($v, string $class) +{ + return new $class($v); +} + +/** + * @template T + * @template K of IFoo + * @param T $v + * @param class-string $class + * @return K + */ +function make2($v, string $class) +{ + return new $class($v); +} + +$obj1 = make1(1, Foo::class); +assertType('Bug4971\Foo', $obj1); + +$obj2 = make2(1, Foo::class); +assertType('Bug4971\Foo', $obj2);