Skip to content

Commit 2092cf3

Browse files
committed
fix(type-system): generic sealed class-string match exhaustiveness
GenericClassStringType::tryRemove() passed the GenericObjectType directly to TypeCombinator::remove(), but GenericObjectType::isSuperTypeOf(plain ObjectType) returns Maybe rather than Yes, so the removal was silently skipped. Strips the generic parameters before delegating to TypeCombinator::remove() so the existing sealed subtraction logic in ObjectType::changeSubtractedType() can handle it.
1 parent c36922b commit 2092cf3

File tree

3 files changed

+56
-2
lines changed

3 files changed

+56
-2
lines changed

src/Type/Generic/GenericClassStringType.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
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;
2324
use PHPStan\Type\UnionType;
@@ -222,12 +223,14 @@ public function tryRemove(Type $typeToRemove): ?Type
222223

223224
if ($classReflection->getAllowedSubTypes() !== null) {
224225
$objectTypeToRemove = new ObjectType($typeToRemove->getValue());
225-
$remainingType = TypeCombinator::remove($generic, $objectTypeToRemove);
226+
$baseType = new ObjectType($genericObjectClassNames[0],
227+
$generic instanceof SubtractableType ? $generic->getSubtractedType() : null);
228+
$remainingType = TypeCombinator::remove($baseType, $objectTypeToRemove);
226229
if ($remainingType instanceof NeverType) {
227230
return new NeverType();
228231
}
229232

230-
if (!$remainingType->equals($generic)) {
233+
if (!$remainingType->equals($baseType)) {
231234
return new self($remainingType);
232235
}
233236
}

tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,12 @@ public function testBug12241(): void
453453
$this->analyse([__DIR__ . '/data/bug-12241.php'], []);
454454
}
455455

456+
#[RequiresPhp('>= 8.0')]
457+
public function testGenericSealedClassStringMatch(): void
458+
{
459+
$this->analyse([__DIR__ . '/data/match-generic-sealed-class-string.php'], []);
460+
}
461+
456462
#[RequiresPhp('>= 8.0')]
457463
public function testBug13029(): void
458464
{
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace MatchGenericSealedClassString;
6+
7+
/**
8+
* @template-covariant T
9+
* @phpstan-sealed BarCov|BazCov
10+
*/
11+
abstract class FooCov {}
12+
13+
/** @template-covariant T @extends FooCov<T> */
14+
final class BarCov extends FooCov {}
15+
16+
/** @template-covariant T @extends FooCov<T> */
17+
final class BazCov extends FooCov {}
18+
19+
/** @param FooCov<string> $foo */
20+
function testTemplateCovariant(FooCov $foo): string {
21+
return match ($foo::class) {
22+
BarCov::class => 'bar',
23+
BazCov::class => 'baz',
24+
};
25+
}
26+
27+
/**
28+
* @template T
29+
* @phpstan-sealed BarInv|BazInv
30+
*/
31+
abstract class FooInv {}
32+
33+
/** @template T @extends FooInv<T> */
34+
final class BarInv extends FooInv {}
35+
36+
/** @template T @extends FooInv<T> */
37+
final class BazInv extends FooInv {}
38+
39+
/** @param FooInv<covariant string> $foo */
40+
function testCovariantParam(FooInv $foo): string {
41+
return match ($foo::class) {
42+
BarInv::class => 'bar',
43+
BazInv::class => 'baz',
44+
};
45+
}

0 commit comments

Comments
 (0)