From d25527d53141610fa0231c3e46838c606818612c Mon Sep 17 00:00:00 2001 From: Mathias Hertlein Date: Sun, 29 Mar 2026 10:13:26 +0200 Subject: [PATCH 1/2] fix(type-system): false "unhandled remaining value" in match on ::class for sealed classes Closes phpstan/phpstan#12241 When using match($foo::class) on a @phpstan-sealed class hierarchy, PHPStan reported "Match expression does not handle remaining value" even when all allowed subtypes were covered. This happened because GenericClassStringType::tryRemove() did not consult the sealed hierarchy's allowed subtypes when removing a class-string constant. Extended tryRemove() to progressively subtract allowed subtypes from the class-string type. Each match arm removes one subtype until all are exhausted and the type becomes never, making the match exhaustive. Co-authored-by: Claude --- src/Type/Generic/GenericClassStringType.php | 64 ++++++++ .../nsrt/sealed-class-string-match.php | 154 ++++++++++++++++++ .../Comparison/MatchExpressionRuleTest.php | 11 ++ .../data/match-sealed-class-string.php | 96 +++++++++++ 4 files changed, 325 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php create mode 100644 tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index b4ac3eff88c..e845f122001 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -18,10 +18,13 @@ 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\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_keys; use function count; use function sprintf; @@ -219,6 +222,67 @@ public function tryRemove(Type $typeToRemove): ?Type if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) { return new NeverType(); } + + $allowedSubTypes = $classReflection->getAllowedSubTypes(); + if ($allowedSubTypes !== null) { + $classToRemove = $typeToRemove->getValue(); + + $subtractedClassNames = []; + if ($generic instanceof SubtractableType) { + $existingSubtracted = $generic->getSubtractedType(); + if ($existingSubtracted !== null) { + foreach (TypeUtils::flattenTypes($existingSubtracted) as $type) { + foreach ($type->getObjectClassNames() as $name) { + $subtractedClassNames[$name] = true; + } + } + } + } + + $isAllowedSubType = false; + $remainingAllowedSubTypes = []; + foreach ($allowedSubTypes as $allowedSubType) { + $names = $allowedSubType->getObjectClassNames(); + if (count($names) === 1) { + if ($names[0] === $classToRemove) { + $isAllowedSubType = true; + continue; + } + if (isset($subtractedClassNames[$names[0]])) { + continue; + } + } + $remainingAllowedSubTypes[] = $allowedSubType; + } + + if ($isAllowedSubType) { + if (count($remainingAllowedSubTypes) === 0) { + return new NeverType(); + } + + // Concrete parent: narrow directly to avoid self-referential Foo~Foo subtraction + if ($genericObjectClassNames[0] === $classToRemove) { + if (count($remainingAllowedSubTypes) === 1) { + return new self($remainingAllowedSubTypes[0]); + } + + return new self(TypeCombinator::union(...$remainingAllowedSubTypes)); + } + + $subtractedClassNames[$classToRemove] = true; + $subtractedTypes = []; + foreach (array_keys($subtractedClassNames) as $name) { + $subtractedTypes[] = new ObjectType($name); + } + $newSubtracted = count($subtractedTypes) === 1 + ? $subtractedTypes[0] + : new UnionType($subtractedTypes); + + return new self( + new ObjectType($genericObjectClassNames[0], $newSubtracted), + ); + } + } } } elseif (count($genericObjectClassNames) > 1) { $objectTypeToRemove = new ObjectType($typeToRemove->getValue()); diff --git a/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php b/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php new file mode 100644 index 00000000000..f7869ad5092 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php @@ -0,0 +1,154 @@ += 8.0 + +declare(strict_types = 1); + +namespace SealedClassStringMatch; + +use function PHPStan\Testing\assertType; + +/** @phpstan-sealed Bar|Baz */ +abstract class Foo {} +class Bar extends Foo {} +class Baz extends Foo {} + +function originalIssue(Foo $foo): void { + $class = $foo::class; + assertType('class-string&literal-string', $class); + + if ($class === Bar::class) { + assertType("'SealedClassStringMatch\\\\Bar'", $class); + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === Baz::class) { + assertType("'SealedClassStringMatch\\\\Baz'", $class); + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed FinalA|FinalB */ +abstract class SealedFinal {} +final class FinalA extends SealedFinal {} +final class FinalB extends SealedFinal {} + +function finalSubtypes(SealedFinal $s): void { + $class = $s::class; + + if ($class === FinalA::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === FinalB::class) { + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed X|Y|Z */ +abstract class ThreeWay {} +final class X extends ThreeWay {} +final class Y extends ThreeWay {} +final class Z extends ThreeWay {} + +function threeSubtypes(ThreeWay $t): void { + $class = $t::class; + + if ($class === X::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === Y::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === Z::class) { + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed ConcreteChild */ +class ConcreteParent {} +final class ConcreteChild extends ConcreteParent {} + +function concreteParent(ConcreteParent $c): void { + $class = $c::class; + + if ($class === ConcreteParent::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === ConcreteChild::class) { + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed ImplA|ImplB */ +interface SealedInterface {} +final class ImplA implements SealedInterface {} +final class ImplB implements SealedInterface {} + +function sealedInterface(SealedInterface $i): void { + $class = $i::class; + + if ($class === ImplA::class) { + return; + } + + if ($class === ImplB::class) { + return; + } + + assertType('*NEVER*', $class); +} + +function nonAllowedRemoval(Foo $foo): void { + $class = $foo::class; + + if ($class === X::class) { + return; + } + + assertType('class-string&literal-string', $class); +} + +function falsyBranch(Foo $foo): void { + $class = $foo::class; + + if ($class !== Bar::class) { + assertType('class-string&literal-string', $class); + } +} + +function matchDirect(Foo $foo): void { + $result = match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; + assertType("'Bar'|'Baz'", $result); +} + +function matchViaVariable(Foo $foo): void { + $class = $foo::class; + $result = match ($class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; + assertType("'Bar'|'Baz'", $result); +} diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 09330a874be..d7b061d44e2 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -447,6 +447,17 @@ public function testBug9534(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testSealedClassStringMatch(): void + { + $this->analyse([__DIR__ . '/data/match-sealed-class-string.php'], [ + [ + 'Match expression does not handle remaining value: class-string&literal-string', + 32, + ], + ]); + } + #[RequiresPhp('>= 8.0')] public function testBug13029(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php b/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php new file mode 100644 index 00000000000..33e734af20f --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php @@ -0,0 +1,96 @@ += 8.0 + +declare(strict_types = 1); + +namespace MatchSealedClassString; + +/** @phpstan-sealed Bar|Baz */ +abstract class Foo {} +class Bar extends Foo {} +class Baz extends Foo {} + +function originalIssue(Foo $foo): string { + return match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; +} + +/** @phpstan-sealed FinalA|FinalB */ +abstract class SealedFinal {} +final class FinalA extends SealedFinal {} +final class FinalB extends SealedFinal {} + +function exhaustiveFinal(SealedFinal $s): string { + return match ($s::class) { + FinalA::class => 'A', + FinalB::class => 'B', + }; +} + +function partialMatch(Foo $foo): string { + return match ($foo::class) { // error + Bar::class => 'Bar', + }; +} + +/** @phpstan-sealed X|Y|Z */ +abstract class ThreeWay {} +final class X extends ThreeWay {} +final class Y extends ThreeWay {} +final class Z extends ThreeWay {} + +function exhaustiveThreeWay(ThreeWay $t): string { + return match ($t::class) { + X::class => 'X', + Y::class => 'Y', + Z::class => 'Z', + }; +} + +/** @phpstan-sealed ImplA|ImplB */ +interface SealedInterface {} +final class ImplA implements SealedInterface {} +final class ImplB implements SealedInterface {} + +function sealedInterfaceExhaustive(SealedInterface $i): string { + return match ($i::class) { + ImplA::class => 'A', + ImplB::class => 'B', + }; +} + +/** + * @template T + * @phpstan-sealed GenFoo|GenBar + */ +abstract class GenericSealed { + /** @return T */ + abstract public function value(): mixed; +} + +/** + * @template T + * @extends GenericSealed + */ +class GenFoo extends GenericSealed { + /** @return T */ + public function value(): mixed { throw new \RuntimeException(); } +} + +/** + * @template T + * @extends GenericSealed + */ +class GenBar extends GenericSealed { + /** @return T */ + public function value(): mixed { throw new \RuntimeException(); } +} + +/** @param GenericSealed $s */ +function sealedGenericExhaustive(GenericSealed $s): string { + return match ($s::class) { + GenFoo::class => 'foo', + GenBar::class => 'bar', + }; +} From b15a269735a4ca20bdda16a63c8d6b9b32ac393d Mon Sep 17 00:00:00 2001 From: Mathias Hertlein Date: Sun, 29 Mar 2026 10:13:36 +0200 Subject: [PATCH 2/2] fix(type-system): generic type parameters lost when narrowing via ::class comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When narrowing a union type like Cat|Dog via $a::class === Cat::class, the generic type parameters were discarded — the narrowed type became plain Cat instead of Cat. This caused method return types to be inferred as mixed instead of the concrete generic parameter. Added resolveClassStringComparison() to TypeSpecifier which deduplicates the mirrored $a::class === 'Foo' / 'Foo' === $a::class blocks and preserves generic information through intersection with the current union type and template parameter inference through the class hierarchy. Co-authored-by: Claude --- src/Analyser/TypeSpecifier.php | 116 +++++++++++---- .../class-string-match-preserves-generics.php | 135 ++++++++++++++++++ 2 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index cceef0e7c01..c12e2253346 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -57,6 +57,7 @@ use PHPStan\Type\FloatType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; @@ -86,6 +87,7 @@ use function array_merge; use function array_reverse; use function array_shift; +use function array_values; use function count; use function in_array; use function is_string; @@ -2577,6 +2579,75 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty return $specifiedTypes; } + private function resolveClassStringComparison( + ClassConstFetch $classExpr, + ConstantStringType $constType, + Expr $originalClassExpr, + TypeSpecifierContext $context, + Scope $scope, + Expr $rootExpr, + ): SpecifiedTypes + { + if (!$classExpr->class instanceof Expr) { + throw new ShouldNotHappenException(); + } + + $className = $constType->getValue(); + if ($className === '') { + throw new ShouldNotHappenException(); + } + + if (!$this->reflectionProvider->hasClass($className)) { + return $this->specifyTypesInCondition( + $scope, + new Instanceof_( + $classExpr->class, + new Name($className), + ), + $context, + )->unionWith( + $this->create($originalClassExpr, $constType, $context, $scope), + )->setRootExpr($rootExpr); + } + + $classReflection = $this->reflectionProvider->getClass($className); + $narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal()); + $currentVarType = $scope->getType($classExpr->class); + + $intersected = TypeCombinator::intersect($narrowedType, $currentVarType); + if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) { + $narrowedType = $intersected; + } else { + $currentReflections = $currentVarType->getObjectClassReflections(); + $childTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes(); + if ( + count($childTemplateTypes) > 0 + && count($currentReflections) === 1 + && count($currentReflections[0]->getTemplateTypeMap()->getTypes()) > 0 + ) { + $freshChild = new GenericObjectType($className, array_values($childTemplateTypes)); + $ancestor = $freshChild->getAncestorWithClassName($currentReflections[0]->getName()); + if ($ancestor !== null) { + $inferredMap = $ancestor->inferTemplateTypes($currentVarType); + $resolved = []; + foreach ($childTemplateTypes as $name => $tType) { + $resolved[] = $inferredMap->getType($name) ?? $tType; + } + $narrowedType = new GenericObjectType($className, $resolved); + } + } + } + + return $this->create( + $classExpr->class, + $narrowedType, + $context, + $scope, + )->unionWith( + $this->create($originalClassExpr, $constType, $context, $scope), + )->setRootExpr($rootExpr); + } + private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; @@ -2878,22 +2949,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $rightType->getValue() !== '' && strtolower($unwrappedLeftExpr->name->toString()) === 'class' ) { - if ($this->reflectionProvider->hasClass($rightType->getValue())) { - return $this->create( - $unwrappedLeftExpr->class, - new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - return $this->specifyTypesInCondition( - $scope, - new Instanceof_( - $unwrappedLeftExpr->class, - new Name($rightType->getValue()), - ), + return $this->resolveClassStringComparison( + $unwrappedLeftExpr, + $rightType, + $leftExpr, $context, - )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $scope, + $expr, + ); } $leftType = $scope->getType($leftExpr); @@ -2909,23 +2972,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $leftType->getValue() !== '' && strtolower($unwrappedRightExpr->name->toString()) === 'class' ) { - if ($this->reflectionProvider->hasClass($leftType->getValue())) { - return $this->create( - $unwrappedRightExpr->class, - new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); - } - - return $this->specifyTypesInCondition( - $scope, - new Instanceof_( - $unwrappedRightExpr->class, - new Name($leftType->getValue()), - ), + return $this->resolveClassStringComparison( + $unwrappedRightExpr, + $leftType, + $rightExpr, $context, - )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + $scope, + $expr, + ); } if ($context->false()) { diff --git a/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php b/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php new file mode 100644 index 00000000000..f5716eaf1c5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php @@ -0,0 +1,135 @@ += 8.0 + +declare(strict_types = 1); + +namespace ClassStringMatchPreservesGenerics; + +use function PHPStan\Testing\assertType; + +/** @template T */ +abstract class Animal { + /** @return T */ + abstract public function value(): mixed; +} + +/** + * @template T + * @extends Animal + */ +class Cat extends Animal { + /** @param T $val */ + public function __construct(private mixed $val) {} + /** @return T */ + public function value(): mixed { return $this->val; } +} + +/** + * @template T + * @extends Animal + */ +class Dog extends Animal { + /** @return never */ + public function value(): never { throw new \RuntimeException(); } +} + +/** @param Cat|Dog $a */ +function unionMatchPreservesGeneric(Animal $a): void { + match ($a::class) { + Cat::class => assertType('string', $a->value()), + Dog::class => assertType('never', $a->value()), + }; +} + +/** @param Cat|Dog $a */ +function ifElseClassPreservesGeneric(Animal $a): void { + if ($a::class === Cat::class) { + assertType('int', $a->value()); + } else { + assertType('int', $a->value()); + } +} + +/** @param Animal $a */ +function singleGenericClassMatch(Animal $a): void { + if ($a::class === Cat::class) { + assertType('string', $a->value()); + } +} + +/** @template T */ +final class Box { + /** @param T $val */ + public function __construct(private mixed $val) {} + /** @return T */ + public function get(): mixed { return $this->val; } +} + +/** @param Box $box */ +function finalGenericClassMatch(Box $box): void { + if ($box::class === Box::class) { + assertType('string', $box->get()); + } +} + +/** @param Cat|Dog $a */ +function mirrorCasePreservesGeneric(Animal $a): void { + if (Cat::class === $a::class) { + assertType('float', $a->value()); + } +} + +/** @param Cat>|Dog> $a */ +function matchWithMethodCall(Animal $a): void { + $result = match ($a::class) { + Cat::class => $a->value(), + Dog::class => [], + }; + assertType('array', $result); +} + +/** @param Cat|Dog $a */ +function nonMatchingClass(Animal $a): void { + if ($a::class === \stdClass::class) { + assertType('*NEVER*', $a); + } else { + assertType('ClassStringMatchPreservesGenerics\Cat|ClassStringMatchPreservesGenerics\Dog', $a); + } +} + +/** + * @template T + * @phpstan-sealed GenCat|GenDog + */ +abstract class GenAnimal { + /** @return T */ + abstract public function value(): mixed; +} + +/** + * @template T + * @extends GenAnimal + */ +class GenCat extends GenAnimal { + /** @param T $val */ + public function __construct(private mixed $val) {} + /** @return T */ + public function value(): mixed { return $this->val; } +} + +/** + * @template T + * @extends GenAnimal + */ +class GenDog extends GenAnimal { + /** @return never */ + public function value(): never { throw new \RuntimeException(); } +} + +/** @param GenAnimal $a */ +function sealedGenericMatch(GenAnimal $a): void { + $result = match ($a::class) { + GenCat::class => $a->value(), + GenDog::class => 'fallback', + }; + assertType('string', $result); +}