Skip to content

Commit 2b1c7b1

Browse files
committed
Fix phpstan/phpstan#14413: Preserve generic type parameters when narrowing via ::class comparison
- Added determineExactClassType() helper in TypeSpecifier that intersects the current scope type with a plain ObjectType to extract generic parameters - When generics are found (narrowed type is more specific than plain ObjectType), uses the narrowed type; otherwise falls back to ObjectType with asFinal() - Updated all 3 ::class/get_class narrowing sites to use the new helper - New regression test in tests/PHPStan/Analyser/nsrt/bug-14413.php
1 parent b70fb0f commit 2b1c7b1

2 files changed

Lines changed: 95 additions & 3 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2844,7 +2844,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
28442844
if ($rightType instanceof ConstantStringType && $this->reflectionProvider->hasClass($rightType->getValue())) {
28452845
return $this->create(
28462846
$unwrappedLeftExpr->getArgs()[0]->value,
2847-
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
2847+
$this->determineExactClassType($scope, $unwrappedLeftExpr->getArgs()[0]->value, $rightType->getValue()),
28482848
$context,
28492849
$scope,
28502850
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
@@ -2969,7 +2969,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
29692969
if ($this->reflectionProvider->hasClass($rightType->getValue())) {
29702970
return $this->create(
29712971
$unwrappedLeftExpr->class,
2972-
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
2972+
$this->determineExactClassType($scope, $unwrappedLeftExpr->class, $rightType->getValue()),
29732973
$context,
29742974
$scope,
29752975
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
@@ -3000,7 +3000,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
30003000
if ($this->reflectionProvider->hasClass($leftType->getValue())) {
30013001
return $this->create(
30023002
$unwrappedRightExpr->class,
3003-
new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()),
3003+
$this->determineExactClassType($scope, $unwrappedRightExpr->class, $leftType->getValue()),
30043004
$context,
30053005
$scope,
30063006
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
@@ -3123,4 +3123,20 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
31233123
return (new SpecifiedTypes([], []))->setRootExpr($expr);
31243124
}
31253125

3126+
private function determineExactClassType(Scope $scope, Expr $exprNode, string $className): Type
3127+
{
3128+
$exprType = $scope->getType($exprNode);
3129+
$classReflection = $this->reflectionProvider->getClass($className)->asFinal();
3130+
$asFinalType = new ObjectType($className, classReflection: $classReflection);
3131+
3132+
$plainType = new ObjectType($className);
3133+
$narrowed = TypeCombinator::intersect($exprType, $plainType);
3134+
3135+
if ($plainType->isSuperTypeOf($narrowed)->yes() && !$narrowed->isSuperTypeOf($plainType)->yes()) {
3136+
return $narrowed;
3137+
}
3138+
3139+
return $asFinalType;
3140+
}
3141+
31263142
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14413;
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('Bug14413\Cat<int>', $a);
47+
assertType('int', $a->value());
48+
} else {
49+
assertType('int', $a->value());
50+
}
51+
}
52+
53+
/** @param Cat<float>|Dog<float> $a */
54+
function mirrorCasePreservesGeneric(Animal $a): void {
55+
if (Cat::class === $a::class) {
56+
assertType('float', $a->value());
57+
}
58+
}
59+
60+
/** @param Cat<array<string>>|Dog<array<string>> $a */
61+
function matchWithMethodCall(Animal $a): void {
62+
$result = match ($a::class) {
63+
Cat::class => $a->value(),
64+
Dog::class => [],
65+
};
66+
assertType('array<string>', $result);
67+
}
68+
69+
/** @param Cat<string>|Dog<string> $a */
70+
function nonMatchingClass(Animal $a): void {
71+
if ($a::class === \stdClass::class) {
72+
assertType('*NEVER*', $a);
73+
} else {
74+
assertType('Bug14413\Cat<string>|Bug14413\Dog<string>', $a);
75+
}
76+
}

0 commit comments

Comments
 (0)