Skip to content

Fix false positive comparing identical generic types below level 8 - #6392

Open
calebdw wants to merge 1 commit into
phpstan:2.2.xfrom
calebdw:calebdw/push-vquvsktlxvyr
Open

Fix false positive comparing identical generic types below level 8#6392
calebdw wants to merge 1 commit into
phpstan:2.2.xfrom
calebdw:calebdw/push-vquvsktlxvyr

Conversation

@calebdw

@calebdw calebdw commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Hello!

Closes phpstan/phpstan#13876
Closes phpstan/phpstan#12984
Closes phpstan/phpstan#11041
Closes phpstan/phpstan#10698
Closes phpstan/phpstan#9096

RuleLevelHelper relaxes the nullability of the accepted type but not of the accepting one, which is what lets a nullable value be passed where a non-nullable one is expected below level 8. TypeTraverser applied that relaxation to generic type arguments as well, so the argument of an accepted Collection<string, int|null> became int while the accepting side kept int|null. Compared invariantly, two identical types then stopped matching and the reported message printed the same type on both sides.

Map the arguments of a concrete generic type instead of feeding them through the relaxation. A template type keeps going through the regular traversal, as rebuilding it as a plain generic would lose its bound.

Thanks!

Comment thread src/Rules/RuleLevelHelper.php Outdated
RuleLevelHelper relaxes the nullability of the accepted type but not of
the accepting one, which is what lets a nullable value be passed where a
non-nullable one is expected below level 8. TypeTraverser applied that
relaxation to generic type arguments as well, so the argument of an
accepted Collection<string, int|null> became int while the accepting side
kept int|null. Compared invariantly, two identical types then stopped
matching and the reported message printed the same type on both sides.

Skip the mapper on GenericObjectType and map its children via
Type::traverse() instead, which also preserves subclasses through
recreate().
@calebdw
calebdw force-pushed the calebdw/push-vquvsktlxvyr branch from 8e07b4e to 8ac890b Compare September 8, 2026 13:02
private function traverseWithoutMapping(Type $type, callable $traverse): Type
{
if ($type instanceof GenericObjectType) {
return $type->traverse($traverse);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks promising but I don't think it's that easy.

Currently when you have @template-covariant and you're trying to pass Foo<int|null> into Foo<int> below level 8, it doesn't report an error.

But on the other hand, what we're trying to fix is passing @template (invariant) Foo<int|null> into Foo<int|null> below level 8, it SHOULD NOT report an error.

And also, passing Foo<int> into Foo<int|null> should not be allowed for @template below level 8.

Please verify my statements, I'm not sure all of that is still true. And on which levels these start or stop being reported.

That's why in our experiments we were trying to solve it with this #4210

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.

3 participants