From dd811c3f5c836a098ba5c6f2025d1bfe7727785d Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:17:53 +0000 Subject: [PATCH] Fix sealed class-string match exhaustiveness for generic sealed hierarchies - GenericObjectType::changeSubtractedType() now delegates to parent's sealed type logic, returning NeverType when all allowed subtypes are subtracted - This fixes match expressions on $foo::class reporting "unhandled values" for generic @phpstan-sealed class hierarchies - New regression test in tests/PHPStan/Rules/Comparison/data/bug-14412.php Closes https://github.com/phpstan/phpstan/issues/14412 --- phpstan-baseline.neon | 2 +- src/Type/Generic/GenericObjectType.php | 8 +++ .../Comparison/MatchExpressionRuleTest.php | 6 ++ .../Rules/Comparison/data/bug-14412.php | 57 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14412.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e925137b245..67dabae8fea 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1134,7 +1134,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ObjectType is error-prone and deprecated. Use Type::isObject() or Type::getObjectClassNames() instead.' identifier: phpstanApi.instanceofType - count: 1 + count: 2 path: src/Type/Generic/GenericObjectType.php - diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index 10b516355d0..63a10119e8a 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -405,6 +405,14 @@ protected function recreate(string $className, array $types, ?Type $subtractedTy public function changeSubtractedType(?Type $subtractedType): Type { + $result = parent::changeSubtractedType($subtractedType); + + // Parent handles sealed type exhaustiveness (returning NeverType when all + // allowed subtypes are subtracted, or a single remaining subtype). + if (!$result instanceof ObjectType || $result->getClassName() !== $this->getClassName()) { + return $result; + } + return new self($this->getClassName(), $this->types, $subtractedType, null, $this->variances); } diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 85b8364a323..1f7880fc2a9 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -453,6 +453,12 @@ public function testBug12241(): void $this->analyse([__DIR__ . '/data/bug-12241.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug14412(): void + { + $this->analyse([__DIR__ . '/data/bug-14412.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testBug13029(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14412.php b/tests/PHPStan/Rules/Comparison/data/bug-14412.php new file mode 100644 index 00000000000..35266e75cec --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14412.php @@ -0,0 +1,57 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14412; + +/** + * @template-covariant T + * @phpstan-sealed BarCov|BazCov + */ +abstract class FooCov {} + +/** + * @template-covariant T + * @extends FooCov + */ +final class BarCov extends FooCov {} + +/** + * @template-covariant T + * @extends FooCov + */ +final class BazCov extends FooCov {} + +/** @param FooCov $foo */ +function testTemplateCovariant(FooCov $foo): string { + return match ($foo::class) { + BarCov::class => 'bar', + BazCov::class => 'baz', + }; +} + +/** + * @template T + * @phpstan-sealed BarInv|BazInv + */ +abstract class FooInv {} + +/** + * @template T + * @extends FooInv + */ +final class BarInv extends FooInv {} + +/** + * @template T + * @extends FooInv + */ +final class BazInv extends FooInv {} + +/** @param FooInv $foo */ +function testCovariantParam(FooInv $foo): string { + return match ($foo::class) { + BarInv::class => 'bar', + BazInv::class => 'baz', + }; +}