Skip to content

Commit b15a269

Browse files
mhertclaude
andcommitted
fix(type-system): generic type parameters lost when narrowing via ::class 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 resolveClassStringComparison() to TypeSpecifier which deduplicates the mirrored $a::class === 'Foo' / 'Foo' === $a::class blocks and preserves generic information through intersection with the current union type and template parameter inference through the class hierarchy. Co-authored-by: Claude <noreply@anthropic.com>
1 parent d25527d commit b15a269

2 files changed

Lines changed: 220 additions & 31 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
use PHPStan\Type\FloatType;
5858
use PHPStan\Type\FunctionTypeSpecifyingExtension;
5959
use PHPStan\Type\Generic\GenericClassStringType;
60+
use PHPStan\Type\Generic\GenericObjectType;
6061
use PHPStan\Type\Generic\TemplateType;
6162
use PHPStan\Type\Generic\TemplateTypeHelper;
6263
use PHPStan\Type\Generic\TemplateTypeVariance;
@@ -86,6 +87,7 @@
8687
use function array_merge;
8788
use function array_reverse;
8889
use function array_shift;
90+
use function array_values;
8991
use function count;
9092
use function in_array;
9193
use function is_string;
@@ -2577,6 +2579,75 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
25772579
return $specifiedTypes;
25782580
}
25792581

2582+
private function resolveClassStringComparison(
2583+
ClassConstFetch $classExpr,
2584+
ConstantStringType $constType,
2585+
Expr $originalClassExpr,
2586+
TypeSpecifierContext $context,
2587+
Scope $scope,
2588+
Expr $rootExpr,
2589+
): SpecifiedTypes
2590+
{
2591+
if (!$classExpr->class instanceof Expr) {
2592+
throw new ShouldNotHappenException();
2593+
}
2594+
2595+
$className = $constType->getValue();
2596+
if ($className === '') {
2597+
throw new ShouldNotHappenException();
2598+
}
2599+
2600+
if (!$this->reflectionProvider->hasClass($className)) {
2601+
return $this->specifyTypesInCondition(
2602+
$scope,
2603+
new Instanceof_(
2604+
$classExpr->class,
2605+
new Name($className),
2606+
),
2607+
$context,
2608+
)->unionWith(
2609+
$this->create($originalClassExpr, $constType, $context, $scope),
2610+
)->setRootExpr($rootExpr);
2611+
}
2612+
2613+
$classReflection = $this->reflectionProvider->getClass($className);
2614+
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());
2615+
$currentVarType = $scope->getType($classExpr->class);
2616+
2617+
$intersected = TypeCombinator::intersect($narrowedType, $currentVarType);
2618+
if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) {
2619+
$narrowedType = $intersected;
2620+
} else {
2621+
$currentReflections = $currentVarType->getObjectClassReflections();
2622+
$childTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes();
2623+
if (
2624+
count($childTemplateTypes) > 0
2625+
&& count($currentReflections) === 1
2626+
&& count($currentReflections[0]->getTemplateTypeMap()->getTypes()) > 0
2627+
) {
2628+
$freshChild = new GenericObjectType($className, array_values($childTemplateTypes));
2629+
$ancestor = $freshChild->getAncestorWithClassName($currentReflections[0]->getName());
2630+
if ($ancestor !== null) {
2631+
$inferredMap = $ancestor->inferTemplateTypes($currentVarType);
2632+
$resolved = [];
2633+
foreach ($childTemplateTypes as $name => $tType) {
2634+
$resolved[] = $inferredMap->getType($name) ?? $tType;
2635+
}
2636+
$narrowedType = new GenericObjectType($className, $resolved);
2637+
}
2638+
}
2639+
}
2640+
2641+
return $this->create(
2642+
$classExpr->class,
2643+
$narrowedType,
2644+
$context,
2645+
$scope,
2646+
)->unionWith(
2647+
$this->create($originalClassExpr, $constType, $context, $scope),
2648+
)->setRootExpr($rootExpr);
2649+
}
2650+
25802651
private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
25812652
{
25822653
$leftExpr = $expr->left;
@@ -2878,22 +2949,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
28782949
$rightType->getValue() !== '' &&
28792950
strtolower($unwrappedLeftExpr->name->toString()) === 'class'
28802951
) {
2881-
if ($this->reflectionProvider->hasClass($rightType->getValue())) {
2882-
return $this->create(
2883-
$unwrappedLeftExpr->class,
2884-
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
2885-
$context,
2886-
$scope,
2887-
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
2888-
}
2889-
return $this->specifyTypesInCondition(
2890-
$scope,
2891-
new Instanceof_(
2892-
$unwrappedLeftExpr->class,
2893-
new Name($rightType->getValue()),
2894-
),
2952+
return $this->resolveClassStringComparison(
2953+
$unwrappedLeftExpr,
2954+
$rightType,
2955+
$leftExpr,
28952956
$context,
2896-
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
2957+
$scope,
2958+
$expr,
2959+
);
28972960
}
28982961

28992962
$leftType = $scope->getType($leftExpr);
@@ -2909,23 +2972,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
29092972
$leftType->getValue() !== '' &&
29102973
strtolower($unwrappedRightExpr->name->toString()) === 'class'
29112974
) {
2912-
if ($this->reflectionProvider->hasClass($leftType->getValue())) {
2913-
return $this->create(
2914-
$unwrappedRightExpr->class,
2915-
new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()),
2916-
$context,
2917-
$scope,
2918-
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
2919-
}
2920-
2921-
return $this->specifyTypesInCondition(
2922-
$scope,
2923-
new Instanceof_(
2924-
$unwrappedRightExpr->class,
2925-
new Name($leftType->getValue()),
2926-
),
2975+
return $this->resolveClassStringComparison(
2976+
$unwrappedRightExpr,
2977+
$leftType,
2978+
$rightExpr,
29272979
$context,
2928-
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
2980+
$scope,
2981+
$expr,
2982+
);
29292983
}
29302984

29312985
if ($context->false()) {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace ClassStringMatchPreservesGenerics;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/** @template T */
10+
abstract class Animal {
11+
/** @return T */
12+
abstract public function value(): mixed;
13+
}
14+
15+
/**
16+
* @template T
17+
* @extends Animal<T>
18+
*/
19+
class Cat extends Animal {
20+
/** @param T $val */
21+
public function __construct(private mixed $val) {}
22+
/** @return T */
23+
public function value(): mixed { return $this->val; }
24+
}
25+
26+
/**
27+
* @template T
28+
* @extends Animal<T>
29+
*/
30+
class Dog extends Animal {
31+
/** @return never */
32+
public function value(): never { throw new \RuntimeException(); }
33+
}
34+
35+
/** @param Cat<string>|Dog<string> $a */
36+
function unionMatchPreservesGeneric(Animal $a): void {
37+
match ($a::class) {
38+
Cat::class => assertType('string', $a->value()),
39+
Dog::class => assertType('never', $a->value()),
40+
};
41+
}
42+
43+
/** @param Cat<int>|Dog<int> $a */
44+
function ifElseClassPreservesGeneric(Animal $a): void {
45+
if ($a::class === Cat::class) {
46+
assertType('int', $a->value());
47+
} else {
48+
assertType('int', $a->value());
49+
}
50+
}
51+
52+
/** @param Animal<string> $a */
53+
function singleGenericClassMatch(Animal $a): void {
54+
if ($a::class === Cat::class) {
55+
assertType('string', $a->value());
56+
}
57+
}
58+
59+
/** @template T */
60+
final class Box {
61+
/** @param T $val */
62+
public function __construct(private mixed $val) {}
63+
/** @return T */
64+
public function get(): mixed { return $this->val; }
65+
}
66+
67+
/** @param Box<string> $box */
68+
function finalGenericClassMatch(Box $box): void {
69+
if ($box::class === Box::class) {
70+
assertType('string', $box->get());
71+
}
72+
}
73+
74+
/** @param Cat<float>|Dog<float> $a */
75+
function mirrorCasePreservesGeneric(Animal $a): void {
76+
if (Cat::class === $a::class) {
77+
assertType('float', $a->value());
78+
}
79+
}
80+
81+
/** @param Cat<array<string>>|Dog<array<string>> $a */
82+
function matchWithMethodCall(Animal $a): void {
83+
$result = match ($a::class) {
84+
Cat::class => $a->value(),
85+
Dog::class => [],
86+
};
87+
assertType('array<string>', $result);
88+
}
89+
90+
/** @param Cat<string>|Dog<string> $a */
91+
function nonMatchingClass(Animal $a): void {
92+
if ($a::class === \stdClass::class) {
93+
assertType('*NEVER*', $a);
94+
} else {
95+
assertType('ClassStringMatchPreservesGenerics\Cat<string>|ClassStringMatchPreservesGenerics\Dog<string>', $a);
96+
}
97+
}
98+
99+
/**
100+
* @template T
101+
* @phpstan-sealed GenCat|GenDog
102+
*/
103+
abstract class GenAnimal {
104+
/** @return T */
105+
abstract public function value(): mixed;
106+
}
107+
108+
/**
109+
* @template T
110+
* @extends GenAnimal<T>
111+
*/
112+
class GenCat extends GenAnimal {
113+
/** @param T $val */
114+
public function __construct(private mixed $val) {}
115+
/** @return T */
116+
public function value(): mixed { return $this->val; }
117+
}
118+
119+
/**
120+
* @template T
121+
* @extends GenAnimal<T>
122+
*/
123+
class GenDog extends GenAnimal {
124+
/** @return never */
125+
public function value(): never { throw new \RuntimeException(); }
126+
}
127+
128+
/** @param GenAnimal<string> $a */
129+
function sealedGenericMatch(GenAnimal $a): void {
130+
$result = match ($a::class) {
131+
GenCat::class => $a->value(),
132+
GenDog::class => 'fallback',
133+
};
134+
assertType('string', $result);
135+
}

0 commit comments

Comments
 (0)