From f7568a19e427a4567dc3e99d471838064e224db1 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:45:03 +0000 Subject: [PATCH 1/2] Widen inferred template type arguments that only refine one of the bound's alternatives - `TemplateTypeHelper::generalizeInferredTemplateType()` now widens a non-covariant template type argument to the bound alternative it merely refines, so `new LanguageProperty(['abc'])` with `@template Value of string|list` is inferred as `LanguageProperty>` instead of `LanguageProperty`. - Widening only happens when the inferred type says nothing more than the bound alternative: array shapes are compared key type to key type and value type to value type, everything else is compared against its `lessSpecific` generalization. Bounds that are more general than the argument (`@template T`, `@template T of array`, `@template T of array`) keep the precise type, and object types keep their class. - The same widening also covers accessory-refined scalars reported in the issue comments: `non-falsy-string` inferred for a `string|list` bound now becomes `string`. - Analogous cases fixed by the same change (each covered by a test): static factory methods returning `self`, generic functions returning `C`, nested list bounds, `iterable` bounds, `non-empty-list` and integer-range refinements, empty arrays, and array-map bounds. The property/static-property/argument/return rule errors all disappear because they share the inference root cause. --- src/Type/Generic/TemplateTypeHelper.php | 76 ++++++- tests/PHPStan/Analyser/nsrt/bug-15027.php | 207 ++++++++++++++++++ .../TypesAssignedToPropertiesRuleTest.php | 5 + .../Rules/Properties/data/bug-15027.php | 43 ++++ 4 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15027.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-15027.php diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index 41028207124..e14bae5f2ea 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -8,6 +8,7 @@ use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; final class TemplateTypeHelper @@ -146,16 +147,77 @@ public static function toArgument(Type $type): Type public static function generalizeInferredTemplateType(TemplateType $templateType, Type $type): Type { - if (!$templateType->getVariance()->covariant()) { - $isArrayKey = $templateType->getBound()->describe(VerbosityLevel::precise()) === '(int|string)'; - if ($type->isScalar()->yes() && $isArrayKey) { - $type = $type->generalize(GeneralizePrecision::templateArgument()); - } elseif ($type->isConstantValue()->yes() && (!$templateType->getBound()->isScalar()->yes() || $isArrayKey)) { - $type = $type->generalize(GeneralizePrecision::templateArgument()); + if ($templateType->getVariance()->covariant()) { + return $type; + } + + $bound = $templateType->getBound(); + $isArrayKey = $bound->describe(VerbosityLevel::precise()) === '(int|string)'; + if ($bound->isScalar()->yes() && !$isArrayKey) { + return $type; + } + + if ($type->isScalar()->yes() && $isArrayKey) { + $type = $type->generalize(GeneralizePrecision::templateArgument()); + } elseif ($type->isConstantValue()->yes()) { + $type = $type->generalize(GeneralizePrecision::templateArgument()); + } + + return self::widenToBoundType($bound, $type); + } + + /** + * An invariant template type argument that merely refines one of the bound's + * alternatives is unusable - Foo is not accepted by Foo>. + * Such a type is widened to the bound alternative it refines. + */ + private static function widenToBoundType(Type $bound, Type $type): Type + { + $boundTypes = $bound instanceof UnionType ? $bound->getTypes() : [$bound]; + + $widenedType = null; + foreach ($boundTypes as $boundType) { + if (!self::onlyRefinesType($boundType, $type)) { + continue; + } + + if ($type->isSuperTypeOf($boundType)->yes()) { + // the type is already as general as the bound alternative + return $type; + } + + if ($widenedType !== null) { + return $type; } + + $widenedType = $boundType; + } + + return $widenedType ?? $type; + } + + /** + * Does $type say the same thing as $superType, only with more precision + * (accessory types, array shapes, literal values)? + */ + private static function onlyRefinesType(Type $superType, Type $type): bool + { + if (!$superType->isSuperTypeOf($type)->yes()) { + return false; + } + + // arrays refine the bound with their shape, so they're compared key type to key type + // and value type to value type instead. Object types are excluded to not lose the class. + if ($type->isArray()->yes() && $superType->isIterable()->yes() && $superType->getObjectClassNames() === []) { + if ($type->isIterableAtLeastOnce()->no()) { + return true; + } + + return self::onlyRefinesType($superType->getIterableKeyType(), $type->getIterableKeyType()) + && self::onlyRefinesType($superType->getIterableValueType(), $type->getIterableValueType()); } - return $type; + return $type->generalize(GeneralizePrecision::lessSpecific())->isSuperTypeOf($superType)->yes(); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15027.php b/tests/PHPStan/Analyser/nsrt/bug-15027.php new file mode 100644 index 00000000000..1689e948e1a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15027.php @@ -0,0 +1,207 @@ += 8.0 + +namespace Bug15027; + +use function PHPStan\Testing\assertType; + +/** + * @template Value of string|list + */ +final class LanguageProperty +{ + /** @var Value */ + public $value; + + /** + * @param Value $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @template T of string|list + * @param T $value + * @return self + */ + public static function create($value): self + { + return new self($value); + } +} + +/** + * @template T + */ +final class Unbounded +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of array + */ +final class BoundToArray +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of array + */ +final class BoundToMixedMap +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of array + */ +final class BoundToStringMap +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of list + */ +final class BoundToList +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of string|list> + */ +final class BoundToNestedList +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of iterable + */ +final class BoundToIterable +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of int|list + */ +final class BoundToIntOrList +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of \DateTimeInterface|string + */ +final class BoundToObjectOrString +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +/** + * @template T of \Traversable + */ +final class BoundToTraversable +{ + /** @param T $value */ + public function __construct(public $value) + { + } +} + +function doFoo(): string +{ + return 'hallo'; +} + +/** + * @template T of string|list + * @param T $value + * @return LanguageProperty + */ +function createLanguageProperty($value): LanguageProperty +{ + return new LanguageProperty($value); +} + +/** + * @param non-empty-list $nonEmptyList + * @param numeric-string $numericString + * @param int<1, 5> $intRange + * @param array $stringMap + */ +function test( + string $s, + array $nonEmptyList, + string $numericString, + int $intRange, + array $stringMap, + \DateTimeImmutable $date, + \ArrayIterator $iterator +): void +{ + assertType('Bug15027\LanguageProperty>', new LanguageProperty(['abc'])); + assertType('Bug15027\LanguageProperty>', new LanguageProperty([$s])); + assertType('Bug15027\LanguageProperty>', new LanguageProperty($nonEmptyList)); + assertType('Bug15027\LanguageProperty>', new LanguageProperty([])); + assertType('Bug15027\LanguageProperty', new LanguageProperty('abc')); + assertType('Bug15027\LanguageProperty', new LanguageProperty('abc' . doFoo())); + assertType('Bug15027\LanguageProperty', new LanguageProperty($numericString)); + + // the same widening applies when the generic type is created by a factory + assertType('Bug15027\LanguageProperty>', LanguageProperty::create(['abc'])); + assertType('Bug15027\LanguageProperty', LanguageProperty::create('abc' . doFoo())); + assertType('Bug15027\LanguageProperty>', createLanguageProperty(['abc'])); + assertType('Bug15027\LanguageProperty', createLanguageProperty('abc' . doFoo())); + + assertType('Bug15027\BoundToList>', new BoundToList(['abc'])); + assertType('Bug15027\BoundToNestedList>>', new BoundToNestedList([['abc']])); + assertType('Bug15027\BoundToIterable>', new BoundToIterable(['abc'])); + assertType('Bug15027\BoundToIntOrList', new BoundToIntOrList($intRange)); + assertType('Bug15027\BoundToIntOrList>', new BoundToIntOrList([1, 2])); + assertType('Bug15027\BoundToStringMap>', new BoundToStringMap(['a' => 'b'])); + assertType('Bug15027\BoundToStringMap>', new BoundToStringMap($stringMap)); + + // bounds that say more than the argument does keep the precise type + assertType('Bug15027\Unbounded', new Unbounded(['abc'])); + assertType('Bug15027\Unbounded', new Unbounded('abc')); + assertType('Bug15027\BoundToArray', new BoundToArray(['abc'])); + assertType('Bug15027\BoundToMixedMap', new BoundToMixedMap(['foo' => 'abc', 'bar' => 1])); + + // class precision is not lost + assertType('Bug15027\BoundToObjectOrString', new BoundToObjectOrString($date)); + assertType('Bug15027\BoundToObjectOrString', new BoundToObjectOrString('abc' . doFoo())); + assertType('Bug15027\BoundToTraversable', new BoundToTraversable($iterator)); +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 4251d2d2d3b..022149154fd 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1067,4 +1067,9 @@ public function testBug10749(): void $this->analyse([__DIR__ . '/data/bug-10749.php'], []); } + public function testBug15027(): void + { + $this->analyse([__DIR__ . '/data/bug-15027.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-15027.php b/tests/PHPStan/Rules/Properties/data/bug-15027.php new file mode 100644 index 00000000000..f5d30a20321 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-15027.php @@ -0,0 +1,43 @@ + + */ +final class LanguageProperty +{ + /** @var Value */ + public $value; + + /** + * @param Value $value + */ + public function __construct($value) + { + $this->value = $value; + } +} + +class Holder +{ + + /** @var LanguageProperty>|null */ + public $lp = null; + + /** @var LanguageProperty|null */ + public static $lps = null; + +} + +function doFoo(): string +{ + return 'hallo'; +} + +function test(): void +{ + $h = new Holder(); + $h->lp = new LanguageProperty(['abc']); + Holder::$lps = new LanguageProperty('abc' . doFoo()); +} From d17ad297f4966608132c23f6f2e91ad68db69736 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Jul 2026 10:35:11 +0000 Subject: [PATCH 2/2] Do not widen an empty array to the array bound it appears to refine array{} carries information no array bound does - it says the array is empty - so widening it to a bound alternative throws that away. It also made the empty case inconsistent with every other array argument: for @template TData of array, new ArrayStruct(['a' => 1]) kept array{a: int} while new ArrayStruct([]) became array. This showed up in shopware, where an optional array $data = [] parameter left ArrayStruct where the class author had declared @extends StoreApiResponse>. Dropping the shortcut lets the regular key-to-key/value-to-value comparison handle empty arrays: their never key and value types are more precise than the bound's, so array{} is preserved. Co-Authored-By: Claude Opus 5 --- src/Type/Generic/TemplateTypeHelper.php | 6 ++---- tests/PHPStan/Analyser/nsrt/bug-15027.php | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index e14bae5f2ea..1c2e8dae608 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -208,11 +208,9 @@ private static function onlyRefinesType(Type $superType, Type $type): bool // arrays refine the bound with their shape, so they're compared key type to key type // and value type to value type instead. Object types are excluded to not lose the class. + // An empty array has never key and value types, which say more than any bound does, + // so array{} is never widened - the class author may well have written Foo. if ($type->isArray()->yes() && $superType->isIterable()->yes() && $superType->getObjectClassNames() === []) { - if ($type->isIterableAtLeastOnce()->no()) { - return true; - } - return self::onlyRefinesType($superType->getIterableKeyType(), $type->getIterableKeyType()) && self::onlyRefinesType($superType->getIterableValueType(), $type->getIterableValueType()); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15027.php b/tests/PHPStan/Analyser/nsrt/bug-15027.php index 1689e948e1a..685581cec1e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15027.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15027.php @@ -53,6 +53,17 @@ public function __construct(public $value) } } +/** + * @template TData of array = array + */ +final class DefaultedArgument +{ + /** @param TData $data */ + public function __construct(public array $data = [], public ?string $alias = null) + { + } +} + /** * @template T of array */ @@ -175,7 +186,6 @@ function test( assertType('Bug15027\LanguageProperty>', new LanguageProperty(['abc'])); assertType('Bug15027\LanguageProperty>', new LanguageProperty([$s])); assertType('Bug15027\LanguageProperty>', new LanguageProperty($nonEmptyList)); - assertType('Bug15027\LanguageProperty>', new LanguageProperty([])); assertType('Bug15027\LanguageProperty', new LanguageProperty('abc')); assertType('Bug15027\LanguageProperty', new LanguageProperty('abc' . doFoo())); assertType('Bug15027\LanguageProperty', new LanguageProperty($numericString)); @@ -200,6 +210,14 @@ function test( assertType('Bug15027\BoundToArray', new BoundToArray(['abc'])); assertType('Bug15027\BoundToMixedMap', new BoundToMixedMap(['foo' => 'abc', 'bar' => 1])); + // an empty array says more than any array bound does, so it is not widened either - + // both when written explicitly and when it comes from an optional parameter's default value + assertType('Bug15027\LanguageProperty', new LanguageProperty([])); + assertType('Bug15027\BoundToList', new BoundToList([])); + assertType('Bug15027\DefaultedArgument', new DefaultedArgument()); + assertType('Bug15027\DefaultedArgument', new DefaultedArgument([])); + assertType('Bug15027\DefaultedArgument', new DefaultedArgument(['a' => 1])); + // class precision is not lost assertType('Bug15027\BoundToObjectOrString', new BoundToObjectOrString($date)); assertType('Bug15027\BoundToObjectOrString', new BoundToObjectOrString('abc' . doFoo()));