Skip to content

Commit 5a8bcae

Browse files
mhertclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 74c90fc commit 5a8bcae

4 files changed

Lines changed: 331 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: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace SealedClassStringMatch;
6+
7+
use function PHPStan\Testing\assertType;
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): void {
15+
$class = $foo::class;
16+
assertType('class-string<SealedClassStringMatch\Foo>&literal-string', $class);
17+
18+
if ($class === Bar::class) {
19+
assertType("'SealedClassStringMatch\\\\Bar'", $class);
20+
return;
21+
}
22+
23+
assertType('class-string<SealedClassStringMatch\Foo~SealedClassStringMatch\Bar>&literal-string', $class);
24+
25+
if ($class === Baz::class) {
26+
assertType("'SealedClassStringMatch\\\\Baz'", $class);
27+
return;
28+
}
29+
30+
assertType('*NEVER*', $class);
31+
}
32+
33+
/** @phpstan-sealed FinalA|FinalB */
34+
abstract class SealedFinal {}
35+
final class FinalA extends SealedFinal {}
36+
final class FinalB extends SealedFinal {}
37+
38+
function finalSubtypes(SealedFinal $s): void {
39+
$class = $s::class;
40+
41+
if ($class === FinalA::class) {
42+
return;
43+
}
44+
45+
assertType('class-string<SealedClassStringMatch\SealedFinal~SealedClassStringMatch\FinalA>&literal-string', $class);
46+
47+
if ($class === FinalB::class) {
48+
return;
49+
}
50+
51+
assertType('*NEVER*', $class);
52+
}
53+
54+
/** @phpstan-sealed X|Y|Z */
55+
abstract class ThreeWay {}
56+
final class X extends ThreeWay {}
57+
final class Y extends ThreeWay {}
58+
final class Z extends ThreeWay {}
59+
60+
function threeSubtypes(ThreeWay $t): void {
61+
$class = $t::class;
62+
63+
if ($class === X::class) {
64+
return;
65+
}
66+
67+
assertType('class-string<SealedClassStringMatch\ThreeWay~SealedClassStringMatch\X>&literal-string', $class);
68+
69+
if ($class === Y::class) {
70+
return;
71+
}
72+
73+
assertType('class-string<SealedClassStringMatch\ThreeWay~(SealedClassStringMatch\X|SealedClassStringMatch\Y)>&literal-string', $class);
74+
75+
if ($class === Z::class) {
76+
return;
77+
}
78+
79+
assertType('*NEVER*', $class);
80+
}
81+
82+
/** @phpstan-sealed ConcreteChild */
83+
class ConcreteParent {}
84+
final class ConcreteChild extends ConcreteParent {}
85+
86+
function concreteParent(ConcreteParent $c): void {
87+
$class = $c::class;
88+
89+
if ($class === ConcreteParent::class) {
90+
return;
91+
}
92+
93+
assertType('class-string<SealedClassStringMatch\ConcreteChild>&literal-string', $class);
94+
95+
if ($class === ConcreteChild::class) {
96+
return;
97+
}
98+
99+
assertType('*NEVER*', $class);
100+
}
101+
102+
/** @phpstan-sealed ImplA|ImplB */
103+
interface SealedInterface {}
104+
final class ImplA implements SealedInterface {}
105+
final class ImplB implements SealedInterface {}
106+
107+
function sealedInterface(SealedInterface $i): void {
108+
$class = $i::class;
109+
110+
if ($class === ImplA::class) {
111+
return;
112+
}
113+
114+
if ($class === ImplB::class) {
115+
return;
116+
}
117+
118+
assertType('*NEVER*', $class);
119+
}
120+
121+
function nonAllowedRemoval(Foo $foo): void {
122+
$class = $foo::class;
123+
124+
if ($class === X::class) {
125+
return;
126+
}
127+
128+
assertType('class-string<SealedClassStringMatch\Foo>&literal-string', $class);
129+
}
130+
131+
function falsyBranch(Foo $foo): void {
132+
$class = $foo::class;
133+
134+
if ($class !== Bar::class) {
135+
assertType('class-string<SealedClassStringMatch\Foo~SealedClassStringMatch\Bar>&literal-string', $class);
136+
}
137+
}
138+
139+
function matchDirect(Foo $foo): void {
140+
$result = match ($foo::class) {
141+
Bar::class => 'Bar',
142+
Baz::class => 'Baz',
143+
};
144+
assertType("'Bar'|'Baz'", $result);
145+
}
146+
147+
function matchViaVariable(Foo $foo): void {
148+
$class = $foo::class;
149+
$result = match ($class) {
150+
Bar::class => 'Bar',
151+
Baz::class => 'Baz',
152+
};
153+
assertType("'Bar'|'Baz'", $result);
154+
}

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+
32,
457+
],
458+
]);
459+
}
460+
450461
#[RequiresPhp('>= 8.0')]
451462
public function testBug13029(): void
452463
{
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace MatchSealedClassString;
6+
7+
/** @phpstan-sealed Bar|Baz */
8+
abstract class Foo {}
9+
class Bar extends Foo {}
10+
class Baz extends Foo {}
11+
12+
function originalIssue(Foo $foo): string {
13+
return match ($foo::class) {
14+
Bar::class => 'Bar',
15+
Baz::class => 'Baz',
16+
};
17+
}
18+
19+
/** @phpstan-sealed FinalA|FinalB */
20+
abstract class SealedFinal {}
21+
final class FinalA extends SealedFinal {}
22+
final class FinalB extends SealedFinal {}
23+
24+
function exhaustiveFinal(SealedFinal $s): string {
25+
return match ($s::class) {
26+
FinalA::class => 'A',
27+
FinalB::class => 'B',
28+
};
29+
}
30+
31+
function partialMatch(Foo $foo): string {
32+
return match ($foo::class) { // error
33+
Bar::class => 'Bar',
34+
};
35+
}
36+
37+
/** @phpstan-sealed X|Y|Z */
38+
abstract class ThreeWay {}
39+
final class X extends ThreeWay {}
40+
final class Y extends ThreeWay {}
41+
final class Z extends ThreeWay {}
42+
43+
function exhaustiveThreeWay(ThreeWay $t): string {
44+
return match ($t::class) {
45+
X::class => 'X',
46+
Y::class => 'Y',
47+
Z::class => 'Z',
48+
};
49+
}
50+
51+
/** @phpstan-sealed ImplA|ImplB */
52+
interface SealedInterface {}
53+
final class ImplA implements SealedInterface {}
54+
final class ImplB implements SealedInterface {}
55+
56+
function sealedInterfaceExhaustive(SealedInterface $i): string {
57+
return match ($i::class) {
58+
ImplA::class => 'A',
59+
ImplB::class => 'B',
60+
};
61+
}
62+
63+
/**
64+
* @template T
65+
* @phpstan-sealed GenFoo|GenBar
66+
*/
67+
abstract class GenericSealed {
68+
/** @return T */
69+
abstract public function value(): mixed;
70+
}
71+
72+
/**
73+
* @template T
74+
* @extends GenericSealed<T>
75+
*/
76+
class GenFoo extends GenericSealed {
77+
/** @return T */
78+
public function value(): mixed { throw new \RuntimeException(); }
79+
}
80+
81+
/**
82+
* @template T
83+
* @extends GenericSealed<T>
84+
*/
85+
class GenBar extends GenericSealed {
86+
/** @return T */
87+
public function value(): mixed { throw new \RuntimeException(); }
88+
}
89+
90+
/** @param GenericSealed<string> $s */
91+
function sealedGenericExhaustive(GenericSealed $s): string {
92+
return match ($s::class) {
93+
GenFoo::class => 'foo',
94+
GenBar::class => 'bar',
95+
};
96+
}

0 commit comments

Comments
 (0)