Skip to content

Commit 90a11e5

Browse files
committed
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.
1 parent 006f503 commit 90a11e5

4 files changed

Lines changed: 326 additions & 0 deletions

File tree

src/Type/Generic/GenericClassStringType.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
use PHPStan\Type\ObjectWithoutClassType;
1919
use PHPStan\Type\StaticType;
2020
use PHPStan\Type\StringType;
21+
use PHPStan\Type\SubtractableType;
2122
use PHPStan\Type\Type;
2223
use PHPStan\Type\TypeCombinator;
24+
use PHPStan\Type\TypeUtils;
2325
use PHPStan\Type\UnionType;
2426
use PHPStan\Type\VerbosityLevel;
27+
use function array_keys;
2528
use function count;
2629
use function sprintf;
2730

@@ -219,6 +222,73 @@ public function tryRemove(Type $typeToRemove): ?Type
219222
if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) {
220223
return new NeverType();
221224
}
225+
226+
$allowedSubTypes = $classReflection->getAllowedSubTypes();
227+
if ($allowedSubTypes !== null) {
228+
$classToRemove = $typeToRemove->getValue();
229+
230+
// Build O(1) lookup of previously subtracted class names
231+
$subtractedClassNames = [];
232+
if ($generic instanceof SubtractableType) {
233+
$existingSubtracted = $generic->getSubtractedType();
234+
if ($existingSubtracted !== null) {
235+
foreach (TypeUtils::flattenTypes($existingSubtracted) as $type) {
236+
foreach ($type->getObjectClassNames() as $name) {
237+
$subtractedClassNames[$name] = true;
238+
}
239+
}
240+
}
241+
}
242+
243+
// Single pass: verify classToRemove is allowed and collect remaining
244+
$isAllowedSubType = false;
245+
$remainingAllowedSubTypes = [];
246+
foreach ($allowedSubTypes as $allowedSubType) {
247+
$names = $allowedSubType->getObjectClassNames();
248+
if (count($names) === 1) {
249+
if ($names[0] === $classToRemove) {
250+
$isAllowedSubType = true;
251+
continue;
252+
}
253+
if (isset($subtractedClassNames[$names[0]])) {
254+
continue;
255+
}
256+
}
257+
$remainingAllowedSubTypes[] = $allowedSubType;
258+
}
259+
260+
if ($isAllowedSubType) {
261+
if (count($remainingAllowedSubTypes) === 0) {
262+
return new NeverType();
263+
}
264+
265+
// When removing the sealed class itself (concrete non-abstract parent),
266+
// narrow to remaining subtypes directly. ObjectType subtraction would
267+
// create a self-referential type (e.g., Foo~Foo) that incorrectly
268+
// excludes child class-strings due to covariant subtraction semantics.
269+
if ($genericObjectClassNames[0] === $classToRemove) {
270+
if (count($remainingAllowedSubTypes) === 1) {
271+
return new self($remainingAllowedSubTypes[0]);
272+
}
273+
274+
return new self(TypeCombinator::union(...$remainingAllowedSubTypes));
275+
}
276+
277+
// Build subtracted type: previous + new removal
278+
$subtractedClassNames[$classToRemove] = true;
279+
$subtractedTypes = [];
280+
foreach (array_keys($subtractedClassNames) as $name) {
281+
$subtractedTypes[] = new ObjectType($name);
282+
}
283+
$newSubtracted = count($subtractedTypes) === 1
284+
? $subtractedTypes[0]
285+
: new UnionType($subtractedTypes);
286+
287+
return new self(
288+
new ObjectType($genericObjectClassNames[0], $newSubtracted),
289+
);
290+
}
291+
}
222292
}
223293
} elseif (count($genericObjectClassNames) > 1) {
224294
$objectTypeToRemove = new ObjectType($typeToRemove->getValue());
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace SealedClassStringMatch;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
// === Original issue example (phpstan/phpstan#12241) ===
10+
// Non-final subtypes — sealed declaration is trusted
11+
12+
/** @phpstan-sealed Bar|Baz */
13+
abstract class Foo {}
14+
class Bar extends Foo {}
15+
class Baz extends Foo {}
16+
17+
function originalIssue(Foo $foo): void {
18+
$class = $foo::class;
19+
assertType('class-string<SealedClassStringMatch\Foo>&literal-string', $class);
20+
21+
if ($class === Bar::class) {
22+
assertType("'SealedClassStringMatch\\\\Bar'", $class);
23+
return;
24+
}
25+
26+
assertType('class-string<SealedClassStringMatch\Foo~SealedClassStringMatch\Bar>&literal-string', $class);
27+
28+
if ($class === Baz::class) {
29+
assertType("'SealedClassStringMatch\\\\Baz'", $class);
30+
return;
31+
}
32+
33+
assertType('*NEVER*', $class);
34+
}
35+
36+
// === Final subtypes ===
37+
38+
/** @phpstan-sealed FinalA|FinalB */
39+
abstract class SealedFinal {}
40+
final class FinalA extends SealedFinal {}
41+
final class FinalB extends SealedFinal {}
42+
43+
function finalSubtypes(SealedFinal $s): void {
44+
$class = $s::class;
45+
46+
if ($class === FinalA::class) {
47+
return;
48+
}
49+
50+
assertType('class-string<SealedClassStringMatch\SealedFinal~SealedClassStringMatch\FinalA>&literal-string', $class);
51+
52+
if ($class === FinalB::class) {
53+
return;
54+
}
55+
56+
assertType('*NEVER*', $class);
57+
}
58+
59+
// === Three subtypes — progressive narrowing ===
60+
61+
/** @phpstan-sealed X|Y|Z */
62+
abstract class ThreeWay {}
63+
final class X extends ThreeWay {}
64+
final class Y extends ThreeWay {}
65+
final class Z extends ThreeWay {}
66+
67+
function threeSubtypes(ThreeWay $t): void {
68+
$class = $t::class;
69+
70+
if ($class === X::class) {
71+
return;
72+
}
73+
74+
assertType('class-string<SealedClassStringMatch\ThreeWay~SealedClassStringMatch\X>&literal-string', $class);
75+
76+
if ($class === Y::class) {
77+
return;
78+
}
79+
80+
assertType('class-string<SealedClassStringMatch\ThreeWay~(SealedClassStringMatch\X|SealedClassStringMatch\Y)>&literal-string', $class);
81+
82+
if ($class === Z::class) {
83+
return;
84+
}
85+
86+
assertType('*NEVER*', $class);
87+
}
88+
89+
// === Concrete sealed parent (non-abstract — parent itself is an allowed subtype) ===
90+
91+
/** @phpstan-sealed ConcreteChild */
92+
class ConcreteParent {}
93+
final class ConcreteChild extends ConcreteParent {}
94+
95+
function concreteParent(ConcreteParent $c): void {
96+
$class = $c::class;
97+
98+
if ($class === ConcreteParent::class) {
99+
return;
100+
}
101+
102+
assertType('class-string<SealedClassStringMatch\ConcreteChild>&literal-string', $class);
103+
104+
if ($class === ConcreteChild::class) {
105+
return;
106+
}
107+
108+
assertType('*NEVER*', $class);
109+
}
110+
111+
// === Sealed interface ===
112+
113+
/** @phpstan-sealed ImplA|ImplB */
114+
interface SealedInterface {}
115+
final class ImplA implements SealedInterface {}
116+
final class ImplB implements SealedInterface {}
117+
118+
function sealedInterface(SealedInterface $i): void {
119+
$class = $i::class;
120+
121+
if ($class === ImplA::class) {
122+
return;
123+
}
124+
125+
if ($class === ImplB::class) {
126+
return;
127+
}
128+
129+
assertType('*NEVER*', $class);
130+
}
131+
132+
// === Removing a non-allowed class — no narrowing ===
133+
134+
function nonAllowedRemoval(Foo $foo): void {
135+
$class = $foo::class;
136+
137+
if ($class === X::class) {
138+
return;
139+
}
140+
141+
// X is not an allowed subtype of Foo, so no narrowing
142+
assertType('class-string<SealedClassStringMatch\Foo>&literal-string', $class);
143+
}
144+
145+
// === Falsy branch — !== narrows via subtraction ===
146+
147+
function falsyBranch(Foo $foo): void {
148+
$class = $foo::class;
149+
150+
if ($class !== Bar::class) {
151+
assertType('class-string<SealedClassStringMatch\Foo~SealedClassStringMatch\Bar>&literal-string', $class);
152+
}
153+
}
154+
155+
// === Match expression directly on $foo::class ===
156+
157+
function matchDirect(Foo $foo): void {
158+
$result = match ($foo::class) {
159+
Bar::class => 'Bar',
160+
Baz::class => 'Baz',
161+
};
162+
assertType("'Bar'|'Baz'", $result);
163+
}
164+
165+
// === Match with variable assignment ===
166+
167+
function matchViaVariable(Foo $foo): void {
168+
$class = $foo::class;
169+
$result = match ($class) {
170+
Bar::class => 'Bar',
171+
Baz::class => 'Baz',
172+
};
173+
assertType("'Bar'|'Baz'", $result);
174+
}

tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,17 @@ public function testBug9534(): void
447447
]);
448448
}
449449

450+
#[RequiresPhp('>= 8.0')]
451+
public function testSealedClassStringMatch(): void
452+
{
453+
$this->analyse([__DIR__ . '/data/match-sealed-class-string.php'], [
454+
[
455+
'Match expression does not handle remaining value: class-string<MatchSealedClassString\Foo>&literal-string',
456+
38,
457+
],
458+
]);
459+
}
460+
450461
#[RequiresPhp('>= 8.0')]
451462
public function testBug13029(): void
452463
{
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace MatchSealedClassString;
6+
7+
// === Original issue (phpstan/phpstan#12241) — non-final subtypes ===
8+
9+
/** @phpstan-sealed Bar|Baz */
10+
abstract class Foo {}
11+
class Bar extends Foo {}
12+
class Baz extends Foo {}
13+
14+
function originalIssue(Foo $foo): string {
15+
return match ($foo::class) { // no error - exhaustive
16+
Bar::class => 'Bar',
17+
Baz::class => 'Baz',
18+
};
19+
}
20+
21+
// === All final subtypes ===
22+
23+
/** @phpstan-sealed FinalA|FinalB */
24+
abstract class SealedFinal {}
25+
final class FinalA extends SealedFinal {}
26+
final class FinalB extends SealedFinal {}
27+
28+
function exhaustiveFinal(SealedFinal $s): string {
29+
return match ($s::class) { // no error - exhaustive
30+
FinalA::class => 'A',
31+
FinalB::class => 'B',
32+
};
33+
}
34+
35+
// === Partial match — missing subtype ===
36+
37+
function partialMatch(Foo $foo): string {
38+
return match ($foo::class) { // error: does not handle remaining value
39+
Bar::class => 'Bar',
40+
};
41+
}
42+
43+
// === Three subtypes — exhaustive ===
44+
45+
/** @phpstan-sealed X|Y|Z */
46+
abstract class ThreeWay {}
47+
final class X extends ThreeWay {}
48+
final class Y extends ThreeWay {}
49+
final class Z extends ThreeWay {}
50+
51+
function exhaustiveThreeWay(ThreeWay $t): string {
52+
return match ($t::class) { // no error - exhaustive
53+
X::class => 'X',
54+
Y::class => 'Y',
55+
Z::class => 'Z',
56+
};
57+
}
58+
59+
// === Sealed interface ===
60+
61+
/** @phpstan-sealed ImplA|ImplB */
62+
interface SealedInterface {}
63+
final class ImplA implements SealedInterface {}
64+
final class ImplB implements SealedInterface {}
65+
66+
function sealedInterfaceExhaustive(SealedInterface $i): string {
67+
return match ($i::class) { // no error - exhaustive
68+
ImplA::class => 'A',
69+
ImplB::class => 'B',
70+
};
71+
}

0 commit comments

Comments
 (0)