Skip to content

Commit 23b1556

Browse files
committed
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 narrowTypePreservingGenerics() to TypeSpecifier which preserves generic information through two strategies: intersecting with the current union type (which lets TypeCombinator distribute and pick the matching generic member), and inferring template parameters through the class hierarchy for single generic parent types.
1 parent 90a11e5 commit 23b1556

2 files changed

Lines changed: 176 additions & 2 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 60 additions & 2 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,48 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
25772579
return $specifiedTypes;
25782580
}
25792581

2582+
private function narrowTypePreservingGenerics(ObjectType $narrowedType, Type $currentVarType): Type
2583+
{
2584+
// Intersect with current type — for unions (e.g., Cat<string>|Dog<string>),
2585+
// TypeCombinator distributes over members and preserves generic parameters
2586+
$intersected = TypeCombinator::intersect($narrowedType, $currentVarType);
2587+
if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) {
2588+
return $intersected;
2589+
}
2590+
2591+
// For generic parent types (e.g., Animal<string>), resolve child's
2592+
// template parameters through the class hierarchy
2593+
$currentClassReflections = $currentVarType->getObjectClassReflections();
2594+
if (
2595+
count($currentClassReflections) === 1
2596+
&& count($currentClassReflections[0]->getTemplateTypeMap()->getTypes()) > 0
2597+
) {
2598+
$className = $narrowedType->getClassName();
2599+
if ($this->reflectionProvider->hasClass($className)) {
2600+
$childReflection = $this->reflectionProvider->getClass($className);
2601+
$childTemplateTypes = $childReflection->getTemplateTypeMap()->getTypes();
2602+
if (count($childTemplateTypes) > 0) {
2603+
$freshChildType = new GenericObjectType($className, array_values($childTemplateTypes));
2604+
$currentClassNames = $currentVarType->getObjectClassNames();
2605+
$ancestorAsParent = count($currentClassNames) === 1
2606+
? $freshChildType->getAncestorWithClassName($currentClassNames[0])
2607+
: null;
2608+
if ($ancestorAsParent !== null) {
2609+
$inferredMap = $ancestorAsParent->inferTemplateTypes($currentVarType);
2610+
$resolvedTypes = [];
2611+
foreach ($childTemplateTypes as $name => $templateType) {
2612+
$resolved = $inferredMap->getType($name);
2613+
$resolvedTypes[] = $resolved ?? $templateType;
2614+
}
2615+
return new GenericObjectType($className, $resolvedTypes);
2616+
}
2617+
}
2618+
}
2619+
}
2620+
2621+
return $narrowedType;
2622+
}
2623+
25802624
private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
25812625
{
25822626
$leftExpr = $expr->left;
@@ -2879,9 +2923,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
28792923
strtolower($unwrappedLeftExpr->name->toString()) === 'class'
28802924
) {
28812925
if ($this->reflectionProvider->hasClass($rightType->getValue())) {
2926+
$className = $rightType->getValue();
2927+
$classReflection = $this->reflectionProvider->getClass($className);
2928+
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());
2929+
2930+
$currentVarType = $scope->getType($unwrappedLeftExpr->class);
2931+
$narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType);
2932+
28822933
return $this->create(
28832934
$unwrappedLeftExpr->class,
2884-
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
2935+
$narrowedType,
28852936
$context,
28862937
$scope,
28872938
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
@@ -2910,9 +2961,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
29102961
strtolower($unwrappedRightExpr->name->toString()) === 'class'
29112962
) {
29122963
if ($this->reflectionProvider->hasClass($leftType->getValue())) {
2964+
$className = $leftType->getValue();
2965+
$classReflection = $this->reflectionProvider->getClass($className);
2966+
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());
2967+
2968+
$currentVarType = $scope->getType($unwrappedRightExpr->class);
2969+
$narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType);
2970+
29132971
return $this->create(
29142972
$unwrappedRightExpr->class,
2915-
new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()),
2973+
$narrowedType,
29162974
$context,
29172975
$scope,
29182976
)->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)