From 74421a46d7a9d2676ec41ed58337e9140d49c95c Mon Sep 17 00:00:00 2001 From: Mathias Hertlein Date: Mon, 30 Mar 2026 20:02:23 +0200 Subject: [PATCH] fix(type-system): sealed class-string match exhaustiveness for ::class comparisons GenericClassStringType::tryRemove() did not handle @phpstan-sealed hierarchies, so match expressions on $foo::class falsely reported unhandled remaining values even when all allowed subtypes were covered. Delegates to TypeCombinator::remove() which already handles sealed subtraction via ObjectType::changeSubtractedType(). Closes phpstan/phpstan#12241 Co-Authored-By: Ondrej Mirtes --- src/Type/Generic/GenericClassStringType.php | 12 +++++++++++ .../Comparison/MatchExpressionRuleTest.php | 6 ++++++ .../Rules/Comparison/data/bug-12241.php | 20 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-12241.php diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index b4ac3eff88c..3123e533d51 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -219,6 +219,18 @@ public function tryRemove(Type $typeToRemove): ?Type if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) { return new NeverType(); } + + if ($classReflection->getAllowedSubTypes() !== null) { + $objectTypeToRemove = new ObjectType($typeToRemove->getValue()); + $remainingType = TypeCombinator::remove($generic, $objectTypeToRemove); + if ($remainingType instanceof NeverType) { + return new NeverType(); + } + + if (!$remainingType->equals($generic)) { + return new self($remainingType); + } + } } } elseif (count($genericObjectClassNames) > 1) { $objectTypeToRemove = new ObjectType($typeToRemove->getValue()); diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 09330a874be..85b8364a323 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -447,6 +447,12 @@ public function testBug9534(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug12241(): void + { + $this->analyse([__DIR__ . '/data/bug-12241.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testBug13029(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12241.php b/tests/PHPStan/Rules/Comparison/data/bug-12241.php new file mode 100644 index 00000000000..7d8edd3f131 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-12241.php @@ -0,0 +1,20 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug12241; + +/** + * @phpstan-sealed Bar|Baz + */ +abstract class Foo{} + +final class Bar extends Foo{} +final class Baz extends Foo{} + +function (Foo $foo): string { + return match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; +};