Skip to content

Resolve template return type T when a class-string<T> parameter is narrowed to a constant class-string#5864

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-d165epn
Open

Resolve template return type T when a class-string<T> parameter is narrowed to a constant class-string#5864
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-d165epn

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Given a generic function/method with a class-string<T> parameter, PHPStan lost the connection between the parameter and T once the parameter was narrowed to a constant class-string. In the reported snippet, inside if ($className === \stdClass::class) the function returns a stdClass (e.g. (object) [], which is object{}&stdClass) for @return T, and PHPStan wrongly reported "should return T of object but returns stdClass". In that branch T is known to be exactly stdClass, so this is a false positive.

The fix resolves the declared return type's template types from the current scope: when a class-string<T> parameter has been pinned to a constant class-string, T is substituted with that exact class before checking the returned value.

Changes

  • src/Rules/FunctionReturnTypeCheck.php:
    • Added specifyTemplateTypesFromScope(), called at the top of checkReturnType().
    • For each parameter whose declared type isClassString()->yes(), if its current scope type consists solely of constant strings (getConstantStrings() covers the whole type), it infers the template type map via GenericClassStringType::inferTemplateTypes() and substitutes the pinned types into the return type with TypeTraverser.
    • Skips anonymous/arrow functions (their parameters are not on $scope->getFunction()).
  • tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php + tests/PHPStan/Rules/Functions/data/bug-2955.php — function regression test.
  • tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php + tests/PHPStan/Rules/Methods/data/bug-2955.php — method regression test (the check is shared by both rules).

Root cause

Narrowing class-string<T> via === Foo::class replaces the parameter's type with the constant 'Foo', discarding the template type T. The return-type rule used the function's static @return T signature with no scope awareness, so T stayed unresolved and TemplateTypeArgumentStrategy::accepts() (correctly, in general) reported "not always the same as T". The missing piece was re-pinning T from the narrowed parameter.

The pin is only sound for constant class-strings: class-string<T> === Foo::class guarantees the caller's T is exactly Foo (any subclass would fail the ===). It is deliberately not applied to:

  • bare-template parameters narrowed by value (e.g. @param T $x with $x === 5 does not pin T to int(5)), and
  • non-constant class-string narrowing such as is_a($className, Base::class, true), where subclasses still pass and returning new Base() would be unsound.

Both of these continue to report as before, which the regression tests assert.

Test

  • bug-2955.php (functions): test() (returns (object) []) and test2() (returns new \stdClass()) now report no errors; returnsWrongClass() still errors but with the resolved type ("should return stdClass but returns Foo"); notPinnedByIsA() still errors via is_a() ("should return T of object but returns Foo").
  • bug-2955.php (methods): Factory::make() reports no error; Factory::makeWrong() still errors with the resolved stdClass return type.
  • Verified the function test fails before the fix (the pinned cases reported false positives) and passes after.
  • make tests, make phpstan, and make cs-fix all pass.

Fixes phpstan/phpstan#2955

…s 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<T>`, 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 phpstan/phpstan#2955
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

\stdClass not treated as an object with generics

2 participants