From 73f28650dd8fca91d515e7d6b07c080d55d9b2fa Mon Sep 17 00:00:00 2001 From: Mathias Hertlein Date: Thu, 26 Mar 2026 20:41:57 +0100 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. --- src/Type/Generic/GenericClassStringType.php | 70 +++++++ .../nsrt/sealed-class-string-match.php | 174 ++++++++++++++++++ .../Comparison/MatchExpressionRuleTest.php | 11 ++ .../data/match-sealed-class-string.php | 71 +++++++ 4 files changed, 326 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 b4ac3eff88..7b60b3f851 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,73 @@ 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(); + + // Build O(1) lookup of previously subtracted class names + $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; + } + } + } + } + + // Single pass: verify classToRemove is allowed and collect remaining + $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(); + } + + // When removing the sealed class itself (concrete non-abstract parent), + // narrow to remaining subtypes directly. ObjectType subtraction would + // create a self-referential type (e.g., Foo~Foo) that incorrectly + // excludes child class-strings due to covariant subtraction semantics. + if ($genericObjectClassNames[0] === $classToRemove) { + if (count($remainingAllowedSubTypes) === 1) { + return new self($remainingAllowedSubTypes[0]); + } + + return new self(TypeCombinator::union(...$remainingAllowedSubTypes)); + } + + // Build subtracted type: previous + new removal + $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 0000000000..1ad739bd2e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php @@ -0,0 +1,174 @@ += 8.0 + +declare(strict_types = 1); + +namespace SealedClassStringMatch; + +use function PHPStan\Testing\assertType; + +// === Original issue example (phpstan/phpstan#12241) === +// Non-final subtypes — sealed declaration is trusted + +/** @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); +} + +// === Final subtypes === + +/** @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); +} + +// === Three subtypes — progressive narrowing === + +/** @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); +} + +// === Concrete sealed parent (non-abstract — parent itself is an allowed subtype) === + +/** @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); +} + +// === Sealed interface === + +/** @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); +} + +// === Removing a non-allowed class — no narrowing === + +function nonAllowedRemoval(Foo $foo): void { + $class = $foo::class; + + if ($class === X::class) { + return; + } + + // X is not an allowed subtype of Foo, so no narrowing + assertType('class-string&literal-string', $class); +} + +// === Falsy branch — !== narrows via subtraction === + +function falsyBranch(Foo $foo): void { + $class = $foo::class; + + if ($class !== Bar::class) { + assertType('class-string&literal-string', $class); + } +} + +// === Match expression directly on $foo::class === + +function matchDirect(Foo $foo): void { + $result = match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; + assertType("'Bar'|'Baz'", $result); +} + +// === Match with variable assignment === + +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 09330a874b..56d552455e 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', + 38, + ], + ]); + } + #[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 0000000000..1eb506b514 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php @@ -0,0 +1,71 @@ += 8.0 + +declare(strict_types = 1); + +namespace MatchSealedClassString; + +// === Original issue (phpstan/phpstan#12241) — non-final subtypes === + +/** @phpstan-sealed Bar|Baz */ +abstract class Foo {} +class Bar extends Foo {} +class Baz extends Foo {} + +function originalIssue(Foo $foo): string { + return match ($foo::class) { // no error - exhaustive + Bar::class => 'Bar', + Baz::class => 'Baz', + }; +} + +// === All final subtypes === + +/** @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) { // no error - exhaustive + FinalA::class => 'A', + FinalB::class => 'B', + }; +} + +// === Partial match — missing subtype === + +function partialMatch(Foo $foo): string { + return match ($foo::class) { // error: does not handle remaining value + Bar::class => 'Bar', + }; +} + +// === Three subtypes — exhaustive === + +/** @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) { // no error - exhaustive + X::class => 'X', + Y::class => 'Y', + Z::class => 'Z', + }; +} + +// === Sealed interface === + +/** @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) { // no error - exhaustive + ImplA::class => 'A', + ImplB::class => 'B', + }; +} From ab7cbddc7237b7f8f4a7df5e111e2c18cd18aba8 Mon Sep 17 00:00:00 2001 From: Mathias Hertlein Date: Thu, 26 Mar 2026 20:42:06 +0100 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 narrowTypePreservingGenerics() to TypeSpecifier which preserves generic information through two strategies: intersecting with the current union type (which lets TypeCombinator distribute and pick the matching generic member), and inferring template parameters through the class hierarchy for single generic parent types. --- src/Analyser/TypeSpecifier.php | 62 +++++++++- .../class-string-match-preserves-generics.php | 116 ++++++++++++++++++ 2 files changed, 176 insertions(+), 2 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 cceef0e7c0..6b1f44ca27 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,48 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty return $specifiedTypes; } + private function narrowTypePreservingGenerics(ObjectType $narrowedType, Type $currentVarType): Type + { + // Intersect with current type — for unions (e.g., Cat|Dog), + // TypeCombinator distributes over members and preserves generic parameters + $intersected = TypeCombinator::intersect($narrowedType, $currentVarType); + if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) { + return $intersected; + } + + // For generic parent types (e.g., Animal), resolve child's + // template parameters through the class hierarchy + $currentClassReflections = $currentVarType->getObjectClassReflections(); + if ( + count($currentClassReflections) === 1 + && count($currentClassReflections[0]->getTemplateTypeMap()->getTypes()) > 0 + ) { + $className = $narrowedType->getClassName(); + if ($this->reflectionProvider->hasClass($className)) { + $childReflection = $this->reflectionProvider->getClass($className); + $childTemplateTypes = $childReflection->getTemplateTypeMap()->getTypes(); + if (count($childTemplateTypes) > 0) { + $freshChildType = new GenericObjectType($className, array_values($childTemplateTypes)); + $currentClassNames = $currentVarType->getObjectClassNames(); + $ancestorAsParent = count($currentClassNames) === 1 + ? $freshChildType->getAncestorWithClassName($currentClassNames[0]) + : null; + if ($ancestorAsParent !== null) { + $inferredMap = $ancestorAsParent->inferTemplateTypes($currentVarType); + $resolvedTypes = []; + foreach ($childTemplateTypes as $name => $templateType) { + $resolved = $inferredMap->getType($name); + $resolvedTypes[] = $resolved ?? $templateType; + } + return new GenericObjectType($className, $resolvedTypes); + } + } + } + } + + return $narrowedType; + } + private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; @@ -2879,9 +2923,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope strtolower($unwrappedLeftExpr->name->toString()) === 'class' ) { if ($this->reflectionProvider->hasClass($rightType->getValue())) { + $className = $rightType->getValue(); + $classReflection = $this->reflectionProvider->getClass($className); + $narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal()); + + $currentVarType = $scope->getType($unwrappedLeftExpr->class); + $narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType); + return $this->create( $unwrappedLeftExpr->class, - new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()), + $narrowedType, $context, $scope, )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); @@ -2910,9 +2961,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope strtolower($unwrappedRightExpr->name->toString()) === 'class' ) { if ($this->reflectionProvider->hasClass($leftType->getValue())) { + $className = $leftType->getValue(); + $classReflection = $this->reflectionProvider->getClass($className); + $narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal()); + + $currentVarType = $scope->getType($unwrappedRightExpr->class); + $narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType); + return $this->create( $unwrappedRightExpr->class, - new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()), + $narrowedType, $context, $scope, )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); 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 0000000000..b05136ce8e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php @@ -0,0 +1,116 @@ += 8.0 + +declare(strict_types = 1); + +namespace ClassStringMatchPreservesGenerics; + +use function PHPStan\Testing\assertType; + +// === Setup: generic parent with subtypes === + +/** @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(); } +} + +// === 1. Union type: ::class match preserves generic parameter === + +/** @param Cat|Dog $a */ +function unionMatchPreservesGeneric(Animal $a): void { + match ($a::class) { + Cat::class => assertType('string', $a->value()), + Dog::class => assertType('never', $a->value()), + }; +} + +// === 2. If-else with ::class preserves generic parameter === + +/** @param Cat|Dog $a */ +function ifElseClassPreservesGeneric(Animal $a): void { + if ($a::class === Cat::class) { + assertType('int', $a->value()); + } else { + // The falsey branch uses the general === handler (not the ::class special path), + // so the object variable is not narrowed with generics — only the class-string is. + assertType('int', $a->value()); + } +} + +// === 3. Single generic type (not union): ::class narrows and preserves generic === + +/** @param Animal $a */ +function singleGenericClassMatch(Animal $a): void { + if ($a::class === Cat::class) { + assertType('string', $a->value()); + } +} + +// === 4. Final generic class: ::class preserves generic === + +/** @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()); + } +} + +// === 5. Mirror case: 'Foo' === $a::class also preserves generics === + +/** @param Cat|Dog $a */ +function mirrorCasePreservesGeneric(Animal $a): void { + if (Cat::class === $a::class) { + assertType('float', $a->value()); + } +} + +// === 6. Match with multiple arms and method calls === + +/** @param Cat>|Dog> $a */ +function matchWithMethodCall(Animal $a): void { + $result = match ($a::class) { + Cat::class => $a->value(), + Dog::class => [], + }; + assertType('array', $result); +} + +// === 7. Non-matching class: no narrowing side-effects === + +/** @param Cat|Dog $a */ +function nonMatchingClass(Animal $a): void { + if ($a::class === \stdClass::class) { + // Unreachable: stdClass is not in Cat|Dog union, so the intersection is never + assertType('*NEVER*', $a); + } else { + assertType('ClassStringMatchPreservesGenerics\Cat|ClassStringMatchPreservesGenerics\Dog', $a); + } +}