From 2092cf3045ad535c658fcc475a7f19fb6221a8a3 Mon Sep 17 00:00:00 2001 From: Mathias Hertlein Date: Mon, 30 Mar 2026 21:43:11 +0200 Subject: [PATCH] fix(type-system): generic sealed class-string match exhaustiveness GenericClassStringType::tryRemove() passed the GenericObjectType directly to TypeCombinator::remove(), but GenericObjectType::isSuperTypeOf(plain ObjectType) returns Maybe rather than Yes, so the removal was silently skipped. Strips the generic parameters before delegating to TypeCombinator::remove() so the existing sealed subtraction logic in ObjectType::changeSubtractedType() can handle it. --- src/Type/Generic/GenericClassStringType.php | 7 ++- .../Comparison/MatchExpressionRuleTest.php | 6 +++ .../match-generic-sealed-class-string.php | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/match-generic-sealed-class-string.php diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 3123e533d5..d54a7695bc 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -18,6 +18,7 @@ use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StaticType; use PHPStan\Type\StringType; +use PHPStan\Type\SubtractableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; @@ -222,12 +223,14 @@ public function tryRemove(Type $typeToRemove): ?Type if ($classReflection->getAllowedSubTypes() !== null) { $objectTypeToRemove = new ObjectType($typeToRemove->getValue()); - $remainingType = TypeCombinator::remove($generic, $objectTypeToRemove); + $baseType = new ObjectType($genericObjectClassNames[0], + $generic instanceof SubtractableType ? $generic->getSubtractedType() : null); + $remainingType = TypeCombinator::remove($baseType, $objectTypeToRemove); if ($remainingType instanceof NeverType) { return new NeverType(); } - if (!$remainingType->equals($generic)) { + if (!$remainingType->equals($baseType)) { return new self($remainingType); } } diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 85b8364a32..1921574431 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 testGenericSealedClassStringMatch(): void + { + $this->analyse([__DIR__ . '/data/match-generic-sealed-class-string.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testBug13029(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/match-generic-sealed-class-string.php b/tests/PHPStan/Rules/Comparison/data/match-generic-sealed-class-string.php new file mode 100644 index 0000000000..3cd65975ce --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-generic-sealed-class-string.php @@ -0,0 +1,45 @@ += 8.0 + +declare(strict_types = 1); + +namespace MatchGenericSealedClassString; + +/** + * @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', + }; +}