From 772df61fe8a39119179000bd89abd879773cb26c Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:47:16 +0000 Subject: [PATCH] Fix type narrowed too much after identical comparison with never-typed generic method When a generic class is constructed with an empty array default parameter, the template type resolves to `never`. Method return types using this template (e.g. `TElement|null`) then collapse to `null`, causing `===` comparisons against non-null values to be seen as always-false, which makes subsequent code unreachable and narrows variable types to `*NEVER*`. The fix replaces `never` with the template's declared bound when resolving class-level template types in method return types, but only when the return type does not contain conditional types (which need the actual `never` value to evaluate correctly, e.g. `T is never ? false : bool`). Fixes https://github.com/phpstan/phpstan/issues/14281 --- .../ResolvedFunctionVariantWithOriginal.php | 17 +++- tests/PHPStan/Analyser/nsrt/bug-14281.php | 96 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/generics.php | 2 +- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14281.php diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 21108d658ef..54df82ee92b 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -3,6 +3,7 @@ namespace PHPStan\Reflection; use PHPStan\Reflection\Php\ExtendedDummyParameter; +use PHPStan\Type\ConditionalType; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\GenericObjectType; @@ -12,6 +13,7 @@ use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; +use PHPStan\Type\NeverType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; @@ -245,7 +247,16 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance return $traverse($type); }; - return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type { + $containsConditionalType = false; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$containsConditionalType): Type { + if ($type instanceof ConditionalType) { + $containsConditionalType = true; + } + + return $containsConditionalType ? $type : $traverse($type); + }); + + return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb, $containsConditionalType): Type { if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { return TypeTraverser::map($type, $objectCb); } @@ -256,6 +267,10 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance return $traverse($type); } + if ($newType instanceof NeverType && $type->getScope()->getFunctionName() === null && !$containsConditionalType) { + return $traverse($type->getBound()); + } + $variance = TemplateTypeVariance::createInvariant(); foreach ($references as $reference) { // this uses identity to distinguish between different occurrences of the same template type diff --git a/tests/PHPStan/Analyser/nsrt/bug-14281.php b/tests/PHPStan/Analyser/nsrt/bug-14281.php new file mode 100644 index 00000000000..0d6aeba72f7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14281.php @@ -0,0 +1,96 @@ + + */ +abstract class Collection implements \IteratorAggregate, \Countable +{ + /** @var array */ + protected array $elements = []; + + /** @param array $elements */ + public function __construct(array $elements = []) + { + $this->elements = $elements; + } + + /** + * @param array-key $key + * @return TElement|null + */ + public function get($key) + { + return $this->elements[$key] ?? null; + } + + /** @phpstan-impure */ + #[\Override] + public function count(): int + { + return \count($this->elements); + } + + /** @return \Traversable */ + #[\Override] + public function getIterator(): \Traversable + { + yield from $this->elements; + } + + /** @param array $options */ + public function assignRecursive(array $options): static + { + return $this; + } +} + +/** + * @template TElement + * @extends Collection + */ +class TestCollection extends Collection +{ +} + +class CollectionTest +{ + public function testFromAssociative(): void + { + $data = [ + null, + 0, + 'some-string', + new \stdClass(), + ['some' => 'value'], + ]; + + $collection = (new TestCollection())->assignRecursive($data); + + assert(count($collection) === 5); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $data); + + assertType('Bug14281\TestCollection<*NEVER*>', $collection); + assertType('mixed', $collection->get(0)); + assertType('mixed', $collection->get(1)); + + assert($data[0] === $collection->get(0)); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $data); + + assert($data[1] === $collection->get(1)); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $data); + + assert($data[2] === $collection->get(2)); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $data); + + assert($data[3] === $collection->get(3)); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $data); + + assert($data[4] === $collection->get(4)); + assertType("array{null, 0, 'some-string', stdClass, array{some: 'value'}}", $data); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/generics.php b/tests/PHPStan/Analyser/nsrt/generics.php index 77b9a470a6b..0173cd8a4f9 100644 --- a/tests/PHPStan/Analyser/nsrt/generics.php +++ b/tests/PHPStan/Analyser/nsrt/generics.php @@ -931,7 +931,7 @@ public function returnStatic(): self function () { $stdEmpty = new StdClassCollection([]); assertType('PHPStan\Generics\FunctionsAssertType\StdClassCollection<*NEVER*, *NEVER*>', $stdEmpty); - assertType('array{}', $stdEmpty->getAll()); + assertType('array', $stdEmpty->getAll()); $std = new StdClassCollection([new \stdClass()]); assertType('PHPStan\Generics\FunctionsAssertType\StdClassCollection', $std);