From f6b72c401d56f81b7623b9355c110e66faf0051d Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 25 May 2026 21:40:39 +0000 Subject: [PATCH] Do not collapse non-generic types into covariant generic supertypes in unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-generic class (like mysqli_result) implements a generic interface (like Traversable) without declaring type arguments, its ancestor resolves to all-mixed type args. The covariant variance check in isValidVariance treats mixed as universally compatible, causing GenericObjectType::isSuperTypeOf to return yes — which lets TypeCombinator collapse the concrete type into the generic supertype in unions. Fix: in GenericObjectType::isSuperTypeOfInternal, detect when the checked type is a non-generic class whose ancestor has all-mixed covariant type args, and return maybe instead of yes. This prevents union collapsing while preserving the existing behavior for generic classes (like sealed class subtypes) that have mixed args due to their own unresolved template parameters. Closes https://github.com/phpstan/phpstan/issues/10008 --- src/Type/Generic/GenericObjectType.php | 47 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-10008.php | 36 +++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10008.php diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index 7ee37d6d413..0245c2e333c 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -18,6 +18,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; +use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\RecursionGuard; use PHPStan\Type\Type; @@ -161,6 +162,10 @@ private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSupe return $nakedSuperTypeOf->and(IsSuperTypeOfResult::createMaybe()); } + if (!$acceptsContext && $this->ancestorHasAllUnresolvedCovariantArgs($type, $ancestor)) { + return $nakedSuperTypeOf->and(IsSuperTypeOfResult::createMaybe()); + } + if (count($this->types) !== count($ancestor->types)) { return IsSuperTypeOfResult::createNo(); } @@ -209,6 +214,48 @@ private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSupe return $result; } + private function ancestorHasAllUnresolvedCovariantArgs(ObjectType $type, self $ancestor): bool + { + $typeClassReflection = $type->getClassReflection(); + if ($typeClassReflection !== null && $typeClassReflection->isGeneric()) { + return false; + } + + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return false; + } + + $typeList = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()); + $hasCovariantWithMixed = false; + + foreach ($typeList as $i => $templateType) { + if (!isset($ancestor->types[$i]) || !isset($this->types[$i])) { + continue; + } + if (!$templateType instanceof TemplateType) { + continue; + } + + $thisVariance = $this->variances[$i] ?? TemplateTypeVariance::createInvariant(); + $effectiveVariance = $thisVariance->invariant() ? $templateType->getVariance() : $thisVariance; + + if (!$effectiveVariance->covariant()) { + continue; + } + + if (!$ancestor->types[$i] instanceof MixedType || $ancestor->types[$i] instanceof TemplateType) { + return false; + } + + if (!$this->types[$i] instanceof MixedType || $this->types[$i] instanceof TemplateType) { + $hasCovariantWithMixed = true; + } + } + + return $hasCovariantWithMixed; + } + public function getClassReflection(): ?ClassReflection { if ($this->classReflection !== null) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10008.php b/tests/PHPStan/Analyser/nsrt/bug-10008.php new file mode 100644 index 00000000000..8400be0ddc7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10008.php @@ -0,0 +1,36 @@ +}> $t + */ + public function sayHello($r, $t): void + { + $x = $r; + if (rand(0,1)) { + $x = $t; + } + assertType('mysqli_result|Traversable}>', $x); + } + + /** + * @param \Iterator $a + * @param \Traversable $b + */ + public function testDifferentValueTypes($a, $b): void + { + $x = $a; + if (rand(0,1)) { + $x = $b; + } + assertType('Iterator|Traversable', $x); + } +}