Skip to content

Commit 99cd706

Browse files
committed
fix(type-system): preserve generic type parameters when narrowing via ::class comparison
Narrowing a generic union like Cat<string>|Dog<string> via $a::class === Cat::class discarded the generic type parameters — the type became plain Cat instead of Cat<string>. This happened because resolveNormalizedIdentical() constructed a plain ObjectType without consulting the variable's known type for generic information. Extracts the duplicated $a::class === 'Foo' / 'Foo' === $a::class handling into a single resolveClassStringComparison() helper and adds generic-aware narrowing: it first tries TypeCombinator::intersect() with the current variable type (which preserves generics from unions), and falls back to template inference via getAncestorWithClassName() + inferTemplateTypes() for single generic parents.
1 parent dab4101 commit 99cd706

File tree

2 files changed

+250
-31
lines changed

2 files changed

+250
-31
lines changed

src/Analyser/TypeSpecifier.php

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
use PHPStan\Type\FloatType;
5959
use PHPStan\Type\FunctionTypeSpecifyingExtension;
6060
use PHPStan\Type\Generic\GenericClassStringType;
61+
use PHPStan\Type\Generic\GenericObjectType;
6162
use PHPStan\Type\Generic\TemplateType;
6263
use PHPStan\Type\Generic\TemplateTypeHelper;
6364
use PHPStan\Type\Generic\TemplateTypeVariance;
@@ -87,6 +88,7 @@
8788
use function array_merge;
8889
use function array_reverse;
8990
use function array_shift;
91+
use function array_values;
9092
use function count;
9193
use function in_array;
9294
use function is_string;
@@ -2665,6 +2667,74 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
26652667
return $specifiedTypes;
26662668
}
26672669

2670+
private function resolveClassStringComparison(
2671+
ClassConstFetch $classExpr,
2672+
ConstantStringType $constType,
2673+
Expr $originalClassExpr,
2674+
TypeSpecifierContext $context,
2675+
Scope $scope,
2676+
Expr $rootExpr,
2677+
): SpecifiedTypes
2678+
{
2679+
if (!$classExpr->class instanceof Expr) {
2680+
throw new ShouldNotHappenException();
2681+
}
2682+
2683+
$className = $constType->getValue();
2684+
if ($className === '') {
2685+
throw new ShouldNotHappenException();
2686+
}
2687+
2688+
if (!$this->reflectionProvider->hasClass($className)) {
2689+
return $this->specifyTypesInCondition(
2690+
$scope,
2691+
new Instanceof_(
2692+
$classExpr->class,
2693+
new Name($className),
2694+
),
2695+
$context,
2696+
)->unionWith(
2697+
$this->create($originalClassExpr, $constType, $context, $scope),
2698+
)->setRootExpr($rootExpr);
2699+
}
2700+
2701+
$classReflection = $this->reflectionProvider->getClass($className);
2702+
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());
2703+
2704+
// Infer generic type parameters from the current type when narrowing to a child class.
2705+
// For union types (e.g. Cat<string>|Dog<string>), scope application via
2706+
// addTypeToExpression already preserves generics through TypeCombinator::intersect.
2707+
// This inference handles the parent-to-child case (e.g. Animal<string> → Cat<string>).
2708+
$currentVarType = $scope->getType($classExpr->class);
2709+
$currentReflections = $currentVarType->getObjectClassReflections();
2710+
$childTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes();
2711+
if (
2712+
count($childTemplateTypes) > 0
2713+
&& count($currentReflections) === 1
2714+
&& count($currentReflections[0]->getTemplateTypeMap()->getTypes()) > 0
2715+
) {
2716+
$freshChild = new GenericObjectType($className, array_values($childTemplateTypes));
2717+
$ancestor = $freshChild->getAncestorWithClassName($currentReflections[0]->getName());
2718+
if ($ancestor !== null) {
2719+
$inferredMap = $ancestor->inferTemplateTypes($currentVarType);
2720+
$resolved = [];
2721+
foreach ($childTemplateTypes as $name => $tType) {
2722+
$resolved[] = $inferredMap->getType($name) ?? $tType;
2723+
}
2724+
$narrowedType = new GenericObjectType($className, $resolved);
2725+
}
2726+
}
2727+
2728+
return $this->create(
2729+
$classExpr->class,
2730+
$narrowedType,
2731+
$context,
2732+
$scope,
2733+
)->unionWith(
2734+
$this->create($originalClassExpr, $constType, $context, $scope),
2735+
)->setRootExpr($rootExpr);
2736+
}
2737+
26682738
private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
26692739
{
26702740
$leftExpr = $expr->left;
@@ -2966,22 +3036,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
29663036
$rightType->getValue() !== '' &&
29673037
strtolower($unwrappedLeftExpr->name->toString()) === 'class'
29683038
) {
2969-
if ($this->reflectionProvider->hasClass($rightType->getValue())) {
2970-
return $this->create(
2971-
$unwrappedLeftExpr->class,
2972-
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
2973-
$context,
2974-
$scope,
2975-
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
2976-
}
2977-
return $this->specifyTypesInCondition(
2978-
$scope,
2979-
new Instanceof_(
2980-
$unwrappedLeftExpr->class,
2981-
new Name($rightType->getValue()),
2982-
),
3039+
return $this->resolveClassStringComparison(
3040+
$unwrappedLeftExpr,
3041+
$rightType,
3042+
$leftExpr,
29833043
$context,
2984-
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
3044+
$scope,
3045+
$expr,
3046+
);
29853047
}
29863048

29873049
$leftType = $scope->getType($leftExpr);
@@ -2997,23 +3059,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
29973059
$leftType->getValue() !== '' &&
29983060
strtolower($unwrappedRightExpr->name->toString()) === 'class'
29993061
) {
3000-
if ($this->reflectionProvider->hasClass($leftType->getValue())) {
3001-
return $this->create(
3002-
$unwrappedRightExpr->class,
3003-
new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()),
3004-
$context,
3005-
$scope,
3006-
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
3007-
}
3008-
3009-
return $this->specifyTypesInCondition(
3010-
$scope,
3011-
new Instanceof_(
3012-
$unwrappedRightExpr->class,
3013-
new Name($leftType->getValue()),
3014-
),
3062+
return $this->resolveClassStringComparison(
3063+
$unwrappedRightExpr,
3064+
$leftType,
3065+
$rightExpr,
30153066
$context,
3016-
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
3067+
$scope,
3068+
$expr,
3069+
);
30173070
}
30183071

30193072
if ($context->false()) {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace ClassStringGenericNarrowing;
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 Cat<float>|Dog<float> $a */
53+
function mirrorCasePreservesGeneric(Animal $a): void {
54+
if (Cat::class === $a::class) {
55+
assertType('float', $a->value());
56+
}
57+
}
58+
59+
/** @param Cat<array<string>>|Dog<array<string>> $a */
60+
function matchWithMethodCall(Animal $a): void {
61+
$result = match ($a::class) {
62+
Cat::class => $a->value(),
63+
Dog::class => [],
64+
};
65+
assertType('array<string>', $result);
66+
}
67+
68+
/** @param Cat<string>|Dog<string> $a */
69+
function nonMatchingClass(Animal $a): void {
70+
if ($a::class === \stdClass::class) {
71+
assertType('*NEVER*', $a);
72+
} else {
73+
assertType('ClassStringGenericNarrowing\Cat<string>|ClassStringGenericNarrowing\Dog<string>', $a);
74+
}
75+
}
76+
77+
/**
78+
* @template-covariant T
79+
*/
80+
abstract readonly class Option {
81+
/** @return T */
82+
abstract public function value(): mixed;
83+
}
84+
85+
/**
86+
* @template-covariant T
87+
* @extends Option<T>
88+
*/
89+
final readonly class SomeOpt extends Option {
90+
/** @param T $val */
91+
public function __construct(private mixed $val) {}
92+
93+
/** @return T */
94+
public function value(): mixed { return $this->val; }
95+
}
96+
97+
/**
98+
* @template-covariant T
99+
* @extends Option<T>
100+
*/
101+
final readonly class NoneOpt extends Option {
102+
/** @return never */
103+
public function value(): never { throw new \RuntimeException(); }
104+
}
105+
106+
class Organization {}
107+
108+
/**
109+
* @template-covariant T
110+
* @template-covariant E
111+
*/
112+
abstract readonly class Result {
113+
/**
114+
* @template TVal
115+
* @param TVal $value
116+
* @return Ok<TVal, never>
117+
*/
118+
public static function ok(mixed $value): Ok
119+
{
120+
return new Ok($value);
121+
}
122+
}
123+
124+
/**
125+
* @template-covariant T
126+
* @template-covariant E
127+
* @extends Result<T, E>
128+
*/
129+
final readonly class Ok extends Result {
130+
/** @param T $val */
131+
public function __construct(private mixed $val) {}
132+
133+
/** @return T */
134+
public function value(): mixed { return $this->val; }
135+
}
136+
137+
/** @param Option<Organization> $option */
138+
function matchOnGenericParentWithResult(Option $option): void {
139+
match ($option::class) {
140+
SomeOpt::class => assertType('ClassStringGenericNarrowing\Organization', $option->value()),
141+
NoneOpt::class => assertType('never', $option->value()),
142+
};
143+
144+
$result = match ($option::class) {
145+
SomeOpt::class => Result::ok($option->value()),
146+
NoneOpt::class => 'none',
147+
};
148+
assertType("'none'|ClassStringGenericNarrowing\Ok<ClassStringGenericNarrowing\Organization, never>", $result);
149+
}
150+
151+
/** @param Option<string> $option */
152+
function matchOnGenericParent(Option $option): void {
153+
match ($option::class) {
154+
SomeOpt::class => assertType('string', $option->value()),
155+
NoneOpt::class => assertType('never', $option->value()),
156+
};
157+
}
158+
159+
/** @param Option<int> $option */
160+
function ifElseOnGenericParent(Option $option): void {
161+
if ($option::class === SomeOpt::class) {
162+
assertType('int', $option->value());
163+
} else {
164+
assertType('int', $option->value());
165+
}
166+
}

0 commit comments

Comments
 (0)