Skip to content

Commit 0b2a99f

Browse files
committed
Fix generic type parameters lost when narrowing union types via ::class comparison
1 parent bf042e8 commit 0b2a99f

2 files changed

Lines changed: 175 additions & 2 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
use PHPStan\Type\NeverType;
7070
use PHPStan\Type\NonexistentParentClassType;
7171
use PHPStan\Type\NullType;
72+
use PHPStan\Type\Generic\GenericObjectType;
7273
use PHPStan\Type\ObjectType;
7374
use PHPStan\Type\ObjectWithoutClassType;
7475
use PHPStan\Type\ResourceType;
@@ -2577,6 +2578,48 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
25772578
return $specifiedTypes;
25782579
}
25792580

2581+
private function narrowTypePreservingGenerics(ObjectType $narrowedType, Type $currentVarType): Type
2582+
{
2583+
// Intersect with current type — for unions (e.g., Cat<string>|Dog<string>),
2584+
// TypeCombinator distributes over members and preserves generic parameters
2585+
$intersected = TypeCombinator::intersect($narrowedType, $currentVarType);
2586+
if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) {
2587+
return $intersected;
2588+
}
2589+
2590+
// For generic parent types (e.g., Animal<string>), resolve child's
2591+
// template parameters through the class hierarchy
2592+
$currentClassReflections = $currentVarType->getObjectClassReflections();
2593+
if (
2594+
count($currentClassReflections) === 1
2595+
&& count($currentClassReflections[0]->getTemplateTypeMap()->getTypes()) > 0
2596+
) {
2597+
$className = $narrowedType->getClassName();
2598+
if ($this->reflectionProvider->hasClass($className)) {
2599+
$childReflection = $this->reflectionProvider->getClass($className);
2600+
$childTemplateTypes = $childReflection->getTemplateTypeMap()->getTypes();
2601+
if (count($childTemplateTypes) > 0) {
2602+
$freshChildType = new GenericObjectType($className, array_values($childTemplateTypes));
2603+
$currentClassNames = $currentVarType->getObjectClassNames();
2604+
$ancestorAsParent = count($currentClassNames) === 1
2605+
? $freshChildType->getAncestorWithClassName($currentClassNames[0])
2606+
: null;
2607+
if ($ancestorAsParent !== null) {
2608+
$inferredMap = $ancestorAsParent->inferTemplateTypes($currentVarType);
2609+
$resolvedTypes = [];
2610+
foreach ($childTemplateTypes as $name => $templateType) {
2611+
$resolved = $inferredMap->getType($name);
2612+
$resolvedTypes[] = $resolved ?? $templateType;
2613+
}
2614+
return new GenericObjectType($className, $resolvedTypes);
2615+
}
2616+
}
2617+
}
2618+
}
2619+
2620+
return $narrowedType;
2621+
}
2622+
25802623
private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
25812624
{
25822625
$leftExpr = $expr->left;
@@ -2879,9 +2922,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
28792922
strtolower($unwrappedLeftExpr->name->toString()) === 'class'
28802923
) {
28812924
if ($this->reflectionProvider->hasClass($rightType->getValue())) {
2925+
$className = $rightType->getValue();
2926+
$classReflection = $this->reflectionProvider->getClass($className);
2927+
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());
2928+
2929+
$currentVarType = $scope->getType($unwrappedLeftExpr->class);
2930+
$narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType);
2931+
28822932
return $this->create(
28832933
$unwrappedLeftExpr->class,
2884-
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
2934+
$narrowedType,
28852935
$context,
28862936
$scope,
28872937
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
@@ -2910,9 +2960,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
29102960
strtolower($unwrappedRightExpr->name->toString()) === 'class'
29112961
) {
29122962
if ($this->reflectionProvider->hasClass($leftType->getValue())) {
2963+
$className = $leftType->getValue();
2964+
$classReflection = $this->reflectionProvider->getClass($className);
2965+
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());
2966+
2967+
$currentVarType = $scope->getType($unwrappedRightExpr->class);
2968+
$narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType);
2969+
29132970
return $this->create(
29142971
$unwrappedRightExpr->class,
2915-
new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()),
2972+
$narrowedType,
29162973
$context,
29172974
$scope,
29182975
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace ClassStringMatchPreservesGenerics;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
// === Setup: generic parent with subtypes ===
10+
11+
/** @template T */
12+
abstract class Animal {
13+
/** @return T */
14+
abstract public function value(): mixed;
15+
}
16+
17+
/**
18+
* @template T
19+
* @extends Animal<T>
20+
*/
21+
class Cat extends Animal {
22+
/** @param T $val */
23+
public function __construct(private mixed $val) {}
24+
/** @return T */
25+
public function value(): mixed { return $this->val; }
26+
}
27+
28+
/**
29+
* @template T
30+
* @extends Animal<T>
31+
*/
32+
class Dog extends Animal {
33+
/** @return never */
34+
public function value(): never { throw new \RuntimeException(); }
35+
}
36+
37+
// === 1. Union type: ::class match preserves generic parameter ===
38+
39+
/** @param Cat<string>|Dog<string> $a */
40+
function unionMatchPreservesGeneric(Animal $a): void {
41+
match ($a::class) {
42+
Cat::class => assertType('string', $a->value()),
43+
Dog::class => assertType('never', $a->value()),
44+
};
45+
}
46+
47+
// === 2. If-else with ::class preserves generic parameter ===
48+
49+
/** @param Cat<int>|Dog<int> $a */
50+
function ifElseClassPreservesGeneric(Animal $a): void {
51+
if ($a::class === Cat::class) {
52+
assertType('int', $a->value());
53+
} else {
54+
// The falsey branch uses the general === handler (not the ::class special path),
55+
// so the object variable is not narrowed with generics — only the class-string is.
56+
assertType('int', $a->value());
57+
}
58+
}
59+
60+
// === 3. Single generic type (not union): ::class narrows and preserves generic ===
61+
62+
/** @param Animal<string> $a */
63+
function singleGenericClassMatch(Animal $a): void {
64+
if ($a::class === Cat::class) {
65+
assertType('string', $a->value());
66+
}
67+
}
68+
69+
// === 4. Final generic class: ::class preserves generic ===
70+
71+
/** @template T */
72+
final class Box {
73+
/** @param T $val */
74+
public function __construct(private mixed $val) {}
75+
/** @return T */
76+
public function get(): mixed { return $this->val; }
77+
}
78+
79+
/** @param Box<string> $box */
80+
function finalGenericClassMatch(Box $box): void {
81+
if ($box::class === Box::class) {
82+
assertType('string', $box->get());
83+
}
84+
}
85+
86+
// === 5. Mirror case: 'Foo' === $a::class also preserves generics ===
87+
88+
/** @param Cat<float>|Dog<float> $a */
89+
function mirrorCasePreservesGeneric(Animal $a): void {
90+
if (Cat::class === $a::class) {
91+
assertType('float', $a->value());
92+
}
93+
}
94+
95+
// === 6. Match with multiple arms and method calls ===
96+
97+
/** @param Cat<array<string>>|Dog<array<string>> $a */
98+
function matchWithMethodCall(Animal $a): void {
99+
$result = match ($a::class) {
100+
Cat::class => $a->value(),
101+
Dog::class => [],
102+
};
103+
assertType('array<string>', $result);
104+
}
105+
106+
// === 7. Non-matching class: no narrowing side-effects ===
107+
108+
/** @param Cat<string>|Dog<string> $a */
109+
function nonMatchingClass(Animal $a): void {
110+
if ($a::class === \stdClass::class) {
111+
// Unreachable: stdClass is not in Cat|Dog union, so the intersection is never
112+
assertType('*NEVER*', $a);
113+
} else {
114+
assertType('ClassStringMatchPreservesGenerics\Cat<string>|ClassStringMatchPreservesGenerics\Dog<string>', $a);
115+
}
116+
}

0 commit comments

Comments
 (0)