From 9c06248c856a5b601a14884c611fb75844b3e6cb Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:31:43 +0000 Subject: [PATCH] Preserve template bound for enum-case, integer-range, constant-float and constant-bool subtypes in `TemplateTypeFactory::create()` - `TemplateTypeFactory::create()` matched bounds with strict `get_class()` checks, so any bound that was a subtype of a handled base type (without its own dedicated `Template*` class) fell through to the final `TemplateMixedType` catch-all, silently widening the bound to `mixed`. - This lost the generic type after narrowing a template variable, e.g. `TFoo of Foo::*` narrowed via `=== Foo::Abc` became `TFoo of mixed`, producing bogus `argument.type` errors. - Reorder the `GenericObjectType` check before the `ObjectType` check and let any remaining `ObjectType` subtype (e.g. `EnumCaseObjectType`) map to `TemplateObjectType`, keeping the precise bound. - Route `IntegerRangeType` to `TemplateIntegerType`, `ConstantFloatType` to `TemplateFloatType`, and constant booleans (`isTrue()`/`isFalse()`) to `TemplateBooleanType`. - Update `TypeCombinatorTest` data set #67 which was documenting the boolean case as a known bug (`should be T of true`). --- src/Type/Generic/TemplateTypeFactory.php | 19 ++++--- tests/PHPStan/Analyser/nsrt/bug-10083.php | 60 +++++++++++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 5 +- 3 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10083.php diff --git a/src/Type/Generic/TemplateTypeFactory.php b/src/Type/Generic/TemplateTypeFactory.php index fb3b2149bd6..e3193c4ae7f 100644 --- a/src/Type/Generic/TemplateTypeFactory.php +++ b/src/Type/Generic/TemplateTypeFactory.php @@ -7,9 +7,11 @@ use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FloatType; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IterableType; @@ -39,14 +41,17 @@ public static function create(TemplateTypeScope $scope, string $name, ?Type $bou } $boundClass = get_class($bound); - if ($bound instanceof ObjectType && ($boundClass === ObjectType::class || $bound instanceof TemplateType)) { - return new TemplateObjectType($scope, $strategy, $variance, $name, $bound, $default); - } - if ($bound instanceof GenericObjectType && ($boundClass === GenericObjectType::class || $bound instanceof TemplateType)) { return new TemplateGenericObjectType($scope, $strategy, $variance, $name, $bound, $default); } + // Catches plain ObjectType and any other object subtype without a dedicated + // Template* class (e.g. enum-case object types), preserving the precise bound + // instead of widening it to TemplateMixedType. + if ($bound instanceof ObjectType) { + return new TemplateObjectType($scope, $strategy, $variance, $name, $bound, $default); + } + if ($bound instanceof ObjectWithoutClassType && ($boundClass === ObjectWithoutClassType::class || $bound instanceof TemplateType)) { return new TemplateObjectWithoutClassType($scope, $strategy, $variance, $name, $bound, $default); } @@ -71,7 +76,7 @@ public static function create(TemplateTypeScope $scope, string $name, ?Type $bou return new TemplateConstantStringType($scope, $strategy, $variance, $name, $bound, $default); } - if ($bound instanceof IntegerType && ($boundClass === IntegerType::class || $bound instanceof TemplateType)) { + if ($bound instanceof IntegerType && ($boundClass === IntegerType::class || $bound instanceof IntegerRangeType || $bound instanceof TemplateType)) { return new TemplateIntegerType($scope, $strategy, $variance, $name, $bound, $default); } @@ -79,11 +84,11 @@ public static function create(TemplateTypeScope $scope, string $name, ?Type $bou return new TemplateConstantIntegerType($scope, $strategy, $variance, $name, $bound, $default); } - if ($bound instanceof FloatType && ($boundClass === FloatType::class || $bound instanceof TemplateType)) { + if ($bound instanceof FloatType && ($boundClass === FloatType::class || $bound instanceof ConstantFloatType || $bound instanceof TemplateType)) { return new TemplateFloatType($scope, $strategy, $variance, $name, $bound, $default); } - if ($bound instanceof BooleanType && ($boundClass === BooleanType::class || $bound instanceof TemplateType)) { + if ($bound instanceof BooleanType && ($boundClass === BooleanType::class || $bound->isTrue()->yes() || $bound->isFalse()->yes() || $bound instanceof TemplateType)) { return new TemplateBooleanType($scope, $strategy, $variance, $name, $bound, $default); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10083.php b/tests/PHPStan/Analyser/nsrt/bug-10083.php new file mode 100644 index 00000000000..eeffed86e6f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10083.php @@ -0,0 +1,60 @@ += 8.1 + +namespace Bug10083; + +use function PHPStan\Testing\assertType; + +enum Foo +{ + + case Abc; + case Bcd; + +} + +/** + * @template TFoo of Foo::* + * @param TFoo $foo + */ +function checkFoo($foo): void +{ +} + +/** + * @template TFoo of Foo::* + * @param TFoo $foo + */ +function narrowEnum($foo): void +{ + if (Foo::Abc === $foo) { + assertType('TFoo of Bug10083\Foo::Abc (function Bug10083\narrowEnum(), argument)', $foo); + } else { + assertType('TFoo of Bug10083\Foo::Bcd (function Bug10083\narrowEnum(), argument)', $foo); + } + + $filter = Foo::Abc === $foo ? 'Abc' : 'Bcd'; + assertType('TFoo of Bug10083\Foo::Abc (function Bug10083\narrowEnum(), argument)|TFoo of Bug10083\Foo::Bcd (function Bug10083\narrowEnum(), argument)', $foo); + checkFoo($foo); +} + +/** + * @template TInt of int + * @param TInt $int + */ +function narrowIntRange($int): void +{ + if ($int >= 0 && $int <= 5) { + assertType('TInt of int<0, 5> (function Bug10083\narrowIntRange(), argument)', $int); + } +} + +/** + * @template TFloat of 1.0|2.0 + * @param TFloat $float + */ +function narrowFloat($float): void +{ + if ($float === 1.0) { + assertType('TFloat of 1.0 (function Bug10083\narrowFloat(), argument)', $float); + } +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index c35d916315e..44090954245 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -45,6 +45,7 @@ use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\Generic\TemplateBenevolentUnionType; +use PHPStan\Type\Generic\TemplateBooleanType; use PHPStan\Type\Generic\TemplateIntersectionType; use PHPStan\Type\Generic\TemplateMixedType; use PHPStan\Type\Generic\TemplateObjectType; @@ -6222,8 +6223,8 @@ public static function dataRemove(): array TemplateTypeVariance::createInvariant(), ), new ConstantBooleanType(false), - TemplateMixedType::class, // should be TemplateConstantBooleanType - 'T (class Foo, parameter)', // should be T of true + TemplateBooleanType::class, + 'T of true (class Foo, parameter)', ], [ new ObjectShapeType(['foo' => new IntegerType()], []),