From be4380b1aec11c865baffe3dde64556b0b6c1034 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:27:01 +0000 Subject: [PATCH] Resolve template return type `T` when a `class-string` parameter is narrowed to a constant class-string - `FunctionReturnTypeCheck::checkReturnType()` now resolves template types in the declared `@return` type using the current scope, via a new `specifyTemplateTypesFromScope()` helper. - For each parameter whose declared type is a `class-string`, if it has been narrowed to a constant class-string in the current scope (e.g. inside `if ($className === \stdClass::class)`), `T` is inferred to that exact class and substituted into the return type. Returning a value of that class then satisfies `@return T`. - The pin is only applied for `class-string` parameters narrowed to constant class-strings, where `===`/assignment guarantees the caller's `T` exactly. Non-constant narrowing (e.g. `is_a()`) and bare-template parameters are intentionally left untouched, since subtypes can still pass and pinning would be unsound. - Works for unions of constant class-strings (`T` becomes the union) and is shared by the function and method return-type rules. Anonymous functions/arrow functions are skipped because their parameters do not come from `$scope->getFunction()`. - Added regression tests in tests/PHPStan/Rules/Functions/data/bug-2955.php and tests/PHPStan/Rules/Methods/data/bug-2955.php covering the pinned case, the still-wrong case (returning a different class), and the not-pinned `is_a()` case. Closes https://github.com/phpstan/phpstan/issues/2955 --- src/Rules/FunctionReturnTypeCheck.php | 56 ++++++++++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 17 ++++++ .../PHPStan/Rules/Functions/data/bug-2955.php | 57 +++++++++++++++++++ .../Rules/Methods/ReturnTypeRuleTest.php | 10 ++++ tests/PHPStan/Rules/Methods/data/bug-2955.php | 38 +++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-2955.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-2955.php diff --git a/src/Rules/FunctionReturnTypeCheck.php b/src/Rules/FunctionReturnTypeCheck.php index e61965ca3a3..d207262b234 100644 --- a/src/Rules/FunctionReturnTypeCheck.php +++ b/src/Rules/FunctionReturnTypeCheck.php @@ -5,11 +5,16 @@ use Generator; use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Variable; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; +use PHPStan\Type\Generic\TemplateType; +use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\NeverType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\VerbosityLevel; use function sprintf; @@ -38,6 +43,7 @@ public function checkReturnType( ): array { $returnType = TypeUtils::resolveLateResolvableTypes($returnType); + $returnType = $this->specifyTemplateTypesFromScope($scope, $returnType); if ($returnType instanceof NeverType && $returnType->isExplicit()) { return [ @@ -110,4 +116,54 @@ public function checkReturnType( return []; } + /** + * Resolves template types in the declared return type that have been pinned + * to an exact class by narrowing a `class-string` parameter to a constant + * class-string (e.g. `if ($className === Foo::class)`). In such a branch the + * caller's `T` is known to be exactly that class, so returning a value of that + * class satisfies `@return T`. + */ + private function specifyTemplateTypesFromScope(Scope $scope, Type $returnType): Type + { + if (!$returnType->hasTemplateOrLateResolvableType()) { + return $returnType; + } + + $function = $scope->getFunction(); + if ($function === null || $scope->isInAnonymousFunction()) { + return $returnType; + } + + $map = TemplateTypeMap::createEmpty(); + foreach ($function->getParameters() as $parameter) { + $parameterType = $parameter->getType(); + if (!$parameterType->isClassString()->yes()) { + continue; + } + + $scopeType = $scope->getType(new Variable($parameter->getName())); + $constantStrings = $scopeType->getConstantStrings(); + if ($constantStrings === [] || !TypeCombinator::union(...$constantStrings)->equals($scopeType)) { + continue; + } + + $map = $map->union($parameterType->inferTemplateTypes($scopeType)); + } + + if ($map->isEmpty()) { + return $returnType; + } + + return TypeTraverser::map($returnType, static function (Type $type, callable $traverse) use ($map): Type { + if ($type instanceof TemplateType) { + $specifiedType = $map->getType($type->getName()); + if ($specifiedType !== null && !$specifiedType instanceof ErrorType) { + return $specifiedType; + } + } + + return $traverse($type); + }); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index da3ba5d6d56..a22c8797cae 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -123,6 +123,23 @@ public function testBug2723(): void ]); } + public function testBug2955(): void + { + $this->checkExplicitMixed = false; + $this->checkNullables = true; + $this->analyse([__DIR__ . '/data/bug-2955.php'], [ + [ + 'Function Bug2955\returnsWrongClass() should return stdClass but returns Bug2955\Foo.', + 40, + ], + [ + 'Function Bug2955\notPinnedByIsA() should return T of object but returns Bug2955\Foo.', + 53, + 'Type Bug2955\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + public function testBug5706(): void { $this->checkExplicitMixed = false; diff --git a/tests/PHPStan/Rules/Functions/data/bug-2955.php b/tests/PHPStan/Rules/Functions/data/bug-2955.php new file mode 100644 index 00000000000..215d88e9029 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-2955.php @@ -0,0 +1,57 @@ + $className + * @return T + */ +function test(string $className): object { + if ($className === \stdClass::class) { + return (object) []; + } + + return new $className(); +} + +/** + * @template T of object + * @param class-string $className + * @return T + */ +function test2(string $className): object { + if ($className === \stdClass::class) { + return new \stdClass(); + } + + return new $className(); +} + +class Foo {} + +/** + * @template T of object + * @param class-string $className + * @return T + */ +function returnsWrongClass(string $className): object { + if ($className === \stdClass::class) { + return new Foo(); + } + + return new $className(); +} + +/** + * @template T of object + * @param class-string $className + * @return T + */ +function notPinnedByIsA(string $className): object { + if (is_a($className, Foo::class, true)) { + return new Foo(); + } + + return new $className(); +} diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index f6196b99cab..b44f32398bc 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -372,6 +372,16 @@ public function testBug2676(): void $this->analyse([__DIR__ . '/data/bug-2676.php'], []); } + public function testBug2955(): void + { + $this->analyse([__DIR__ . '/data/bug-2955.php'], [ + [ + 'Method Bug2955Method\Factory::makeWrong() should return stdClass but returns Bug2955Method\Foo.', + 32, + ], + ]); + } + public function testBug2885(): void { $this->analyse([__DIR__ . '/data/bug-2885.php'], []); diff --git a/tests/PHPStan/Rules/Methods/data/bug-2955.php b/tests/PHPStan/Rules/Methods/data/bug-2955.php new file mode 100644 index 00000000000..59e76165d23 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-2955.php @@ -0,0 +1,38 @@ + $className + * @return T + */ + public function make(string $className): object + { + if ($className === \stdClass::class) { + return (object) []; + } + + return new $className(); + } + + /** + * @template T of object + * @param class-string $className + * @return T + */ + public function makeWrong(string $className): object + { + if ($className === \stdClass::class) { + return new Foo(); + } + + return new $className(); + } + +}