Skip to content

Fix match exhaustiveness and generic preservation for ::class comparisons#5305

Open
mhert wants to merge 2 commits intophpstan:2.1.xfrom
mhert:fix-sealed-class-match-exhaustiveness
Open

Fix match exhaustiveness and generic preservation for ::class comparisons#5305
mhert wants to merge 2 commits intophpstan:2.1.xfrom
mhert:fix-sealed-class-match-exhaustiveness

Conversation

@mhert
Copy link

@mhert mhert commented Mar 26, 2026

Summary

Two related fixes for ::class comparison type narrowing in match expressions and if-else conditions.

  1. match($foo::class) on a @phpstan-sealed hierarchy falsely reported "Match expression does not handle remaining value" even when all allowed subtypes were covered.
  2. Narrowing a generic union like Cat<string>|Dog<string> via $a::class === Cat::class discarded generic type parameters — the type became plain Cat instead of Cat<string>.

Closes phpstan/phpstan#12241

Root Cause

Sealed exhaustiveness: GenericClassStringType::tryRemove() only handled final classes — it had no awareness of @phpstan-sealed hierarchies. Removing a class-string constant for a non-final sealed subtype was a no-op, so the type was never fully exhausted to never.

Generic preservation: The $a::class === 'Foo' handler in TypeSpecifier::resolveNormalizedIdentical() constructed a plain ObjectType without consulting the current variable type for generic information. The generic parameters from the original union were lost.

Fix

Sealed exhaustiveness: Extended GenericClassStringType::tryRemove() to consult ClassReflection::getAllowedSubTypes(). Each match arm progressively subtracts one allowed subtype via ObjectType subtraction. When all subtypes are removed, the type becomes never, making the match exhaustive. Special-cases concrete (non-abstract) sealed parents to avoid self-referential subtraction.

Generic preservation: Added TypeSpecifier::narrowTypePreservingGenerics() which preserves generics through two strategies:

  • For union types: TypeCombinator::intersect() distributes over members and picks the matching generic member
  • For single generic parents: infers child template parameters through the class hierarchy via inferTemplateTypes()

Test Plan

  • MatchExpressionRuleTest::testSealedClassStringMatch — exhaustive matches produce no error, partial matches report remaining value
  • nsrt/sealed-class-string-match.php — progressive class-string narrowing, concrete parents, sealed interfaces, falsy branch (!==), match expressions
  • nsrt/class-string-match-preserves-generics.php — generic preservation for unions, single parents, final classes, mirror syntax, match arms with method calls
  • All existing tests pass — no regressions

Co-Authored-By: Claude Code

@phpstan-bot
Copy link
Collaborator

You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x.

@mhert mhert force-pushed the fix-sealed-class-match-exhaustiveness branch from e046593 to 3b9ff34 Compare March 26, 2026 19:43
@mhert mhert changed the base branch from 2.2.x to 2.1.x March 26, 2026 19:43
@mhert mhert force-pushed the fix-sealed-class-match-exhaustiveness branch from 3b9ff34 to 708c2c2 Compare March 26, 2026 20:10
@mhert mhert marked this pull request as ready for review March 26, 2026 20:18
@phpstan-bot
Copy link
Collaborator

This pull request has been marked as ready for review.

mhert added 2 commits March 26, 2026 21:21
…ss 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.
…lass comparison

When narrowing a union type like Cat<string>|Dog<string> via
$a::class === Cat::class, the generic type parameters were discarded — the
narrowed type became plain Cat instead of Cat<string>. 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.
@mhert mhert force-pushed the fix-sealed-class-match-exhaustiveness branch from 708c2c2 to 23b1556 Compare March 26, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Supporting sealed class hierarchies in match

2 participants