Skip to content

Ignore the new Foo() exactness flavour when comparing generic type arguments for variance - #6487

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

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-vzs2zfj

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Calling a method whose parameter is written as @param A<covariant static> on a receiver
whose type carries the "exactly this class" flavour ($b = new B()) reported a false
positive:

Parameter #1 $a of method B::method() expects A<covariant B>, A<B> given.

The same call on a plain B parameter was fine, so the error only appeared for receivers
PHPStan knows are exactly B. Variance comparisons between generic type arguments now
ignore that flavour, which is what the invariant comparison already did.

Changes

  • src/Type/Generic/TemplateTypeVariance.php: the covariant and contravariant branches of
    isValidVariance() call a new private compareTypeArguments() which strips the
    final-by-keyword override from both sides (TemplateTypeHelper::removeFinalByKeywordOverrides())
    before isSuperTypeOf().
  • tests/PHPStan/Rules/Methods/data/bug-15235.php + CallMethodsRuleTest::testBug15235().
  • tests/PHPStan/Rules/Properties/data/bug-15235.php + TypesAssignedToPropertiesRuleTest::testBug15235().

Analogous cases covered by the same one-line change, each with its own case in the test data
(all 8 of them fail without the fix):

  • call-site variance @param A<covariant static> on a new B() receiver (the report),
  • the same on a receiver narrowed by get_class($b) === B::class (the flavour also comes
    from IdenticalNarrowingHelper, not just from new),
  • class-declared @template-covariant (@param Covariant<static>),
  • built-in generics with a covariant type parameter (@param \Traversable<int, static>),
  • the flavour on the argument side instead of the expected side, arriving through a
    @return Wrapper<static> factory called on an exact receiver and passed to a
    @param Wrapper<contravariant E>,
  • generic property assignment (@var A<covariant static>, @var Covariant<static>),
  • type arguments nested inside a union, inside array<int, Covariant<static>> and inside
    another generic type.

Probed and found already correct, so no change and no test kept: non-generic @param static
(ObjectType::accepts() is name-based), invariant type arguments (equals() ignores the
flavour), array/iterable/array-shape/callable parameter types (they compare with
accepts()), class-string<static>, and the naked class part of a generic comparison (its
result is discarded once there are type arguments).

Root cause

ClassReflection::asFinal() puts an "exactly this class" flavour on the type of a
new Foo() expression (and on get_class($x) === Foo::class narrowing) so that checks know
the value cannot be a subclass instance. Foo with the flavour is therefore only a
maybe-supertype of a plain Foo.

Nothing written in a PHPDoc can carry that flavour, so any comparison between a flavoured
type and a written one has to ignore it. phpstan/phpstan#15166 established this for inferred
template arguments, which TemplateTypeTrait::inferTemplateTypes() strips. This issue is the
other direction: the flavour reaches a written type when static inside it is substituted
by the called-on type (CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType()
and its property counterpart), producing an expected A<covariant B-exact> that no written
A<B> can match. @return A<static> puts it on the value side the same way.

Rather than stripping it at every substitution site - which would also lose the precision
that makes (new B())->returnsStatic() instanceof Foo impossible - the fix is at the single
place where type arguments are compared for variance. TemplateTypeVariance::isValidVariance()
is the only comparison that was flavour-sensitive: the invariant branch uses equals(), which
ignores the flavour, while the covariant and contravariant branches used isSuperTypeOf(),
which does not. That made covariance stricter than invariance for the very same pair of types.

Test

CallMethodsRuleTest::testBug15235() runs tests/PHPStan/Rules/Methods/data/bug-15235.php,
which starts from the reproducer in the issue (including its D::inconsistent() counter-case
that never errored) and adds the analogous constructs listed above. TypesAssignedToPropertiesRuleTest::testBug15235()
runs the property-assignment counterpart. Without the source change the two tests report 8
false positives; with it both expect no errors. Full make tests and make phpstan are green.

Fixes phpstan/phpstan#15235

…arguments for variance

* `TemplateTypeVariance::isValidVariance()` now routes the covariant and
  contravariant comparisons through a new private `compareTypeArguments()` that
  drops the final-by-keyword override from both type arguments before calling
  `isSuperTypeOf()`. The invariant branch never had the problem because
  `equals()` already ignores the flavour.
* Fixes the reported case (`@param A<covariant static>` called on a `new B()`
  receiver) and, with the same change, the analogous ones found by probing the
  family: a receiver narrowed by `get_class($b) === B::class`, class-declared
  `@template-covariant` / `@template-contravariant` parameters, built-in generics
  (`Traversable<int, static>`), the flavour arriving on the argument side through
  a `@return Wrapper<static>` factory, generic property assignment
  (`@var A<covariant static>`), and type arguments nested inside unions, arrays
  and other generic types.
* Probed and found already correct, so left alone: non-generic `@param static`,
  invariant type arguments, `array`/`iterable`/array-shape/callable parameter
  types, `class-string<static>`, and `instanceof` narrowing - the flavour is
  still preserved everywhere else, so `(new B())->returnsStatic() instanceof Foo`
  stays impossible.
* Regression tests in `tests/PHPStan/Rules/Methods/data/bug-15235.php` and
  `tests/PHPStan/Rules/Properties/data/bug-15235.php`; without the fix they
  report 8 false positives.
*/
private static function compareTypeArguments(Type $super, Type $sub): IsSuperTypeOfResult
{
return TemplateTypeHelper::removeFinalByKeywordOverrides($super)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is almost a one liner, this is not worth introducing a private method.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still running — I'll report once both complete.

@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026
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.

"Parameter #1 expects A<covariant B>, A<B> given" error since 2.2.13

2 participants