Skip to content

Commit e8528b0

Browse files
SanderMullerclaude
andcommitted
Generalize an inferred template type by what a conditional branch knows about it
In the else branch of `(TNewKey is \UnitEnum ? array-key : TNewKey)` with TNewKey of array-key|\UnitEnum, the branch knows TNewKey is an array-key. Since the branches hold NarrowedSubjectType references, the resolver traversed into them and generalized the inferred type against the subject's own bound, which is not scalar, so a literal key such as 'foo' became string. 2.2.14 kept 'foo'. The resolver now resolves the subject inside a NarrowedSubjectType without generalizing it, and generalizes the narrowed result against the reference, whose bound is the narrowed one. A template type in the target stays generalized, as the condition sees it: otherwise `(T is U ? T : int)` narrowed 'foo' & 'bar' to never. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 156f1ff commit e8528b0

2 files changed

Lines changed: 157 additions & 39 deletions

File tree

‎src/Reflection/ResolvedFunctionVariantWithOriginal.php‎

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Type\Generic\TemplateType;
1111
use PHPStan\Type\Generic\TemplateTypeHelper;
1212
use PHPStan\Type\Generic\TemplateTypeMap;
13+
use PHPStan\Type\Generic\TemplateTypeReference;
1314
use PHPStan\Type\Generic\TemplateTypeVariance;
1415
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
1516
use PHPStan\Type\NarrowedSubjectType;
@@ -230,19 +231,19 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
230231
{
231232
$references = $type->getReferencedTemplateTypes($positionVariance);
232233

233-
$objectCb = function (Type $type, callable $traverse) use ($references): Type {
234-
if (
235-
$type instanceof TemplateType
236-
&& !$type instanceof NarrowedSubjectType
237-
&& !$type->isArgument()
238-
&& $type->getScope()->getFunctionName() !== null
239-
) {
234+
$objectCb = fn (Type $type, callable $traverse): Type => $this->resolveTemplateTypeInGenericType($type, $traverse, $references, null);
235+
236+
return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type {
237+
if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) {
238+
return TypeTraverser::map($type, $objectCb);
239+
}
240+
241+
if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) {
240242
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
241243
if ($newType === null || $newType instanceof ErrorType) {
242244
return $traverse($type);
243245
}
244246

245-
$newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType);
246247
$variance = TemplateTypeVariance::createInvariant();
247248
foreach ($references as $reference) {
248249
// this uses identity to distinguish between different occurrences of the same template type
@@ -270,47 +271,74 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
270271
}
271272

272273
return $traverse($type);
273-
};
274+
});
275+
}
274276

275-
return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type {
276-
if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) {
277-
return TypeTraverser::map($type, $objectCb);
278-
}
277+
/**
278+
* @param callable(Type): Type $traverse
279+
* @param list<TemplateTypeReference> $references
280+
* @param string|null $keepInferredName a template type whose inferred type is not generalized here
281+
*/
282+
private function resolveTemplateTypeInGenericType(Type $type, callable $traverse, array $references, ?string $keepInferredName): Type
283+
{
284+
if (
285+
$type instanceof NarrowedSubjectType
286+
&& $keepInferredName === null
287+
&& !$type->isArgument()
288+
&& $type->getScope()->getFunctionName() !== null
289+
) {
290+
// generalize what the branch knows about the subject, not the subject: in the
291+
// else branch of `(T is \UnitEnum ? array-key : T)` with T of array-key|\UnitEnum,
292+
// T is a scalar and an inferred 'foo' stays 'foo'. Only the subject is taken as
293+
// inferred - a template type in the target is generalized as the condition sees it.
294+
$subjectName = $type->getName();
295+
$resolved = TypeTraverser::map($type, fn (Type $type, callable $traverse): Type => $this->resolveTemplateTypeInGenericType($type, $traverse, $references, $subjectName));
296+
297+
return TemplateTypeHelper::generalizeInferredTemplateType($type, $resolved);
298+
}
279299

280-
if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) {
281-
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
282-
if ($newType === null || $newType instanceof ErrorType) {
283-
return $traverse($type);
284-
}
300+
if (
301+
$type instanceof TemplateType
302+
&& !$type instanceof NarrowedSubjectType
303+
&& !$type->isArgument()
304+
&& $type->getScope()->getFunctionName() !== null
305+
) {
306+
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
307+
if ($newType === null || $newType instanceof ErrorType) {
308+
return $traverse($type);
309+
}
285310

286-
$variance = TemplateTypeVariance::createInvariant();
287-
foreach ($references as $reference) {
288-
// this uses identity to distinguish between different occurrences of the same template type
289-
// see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details
290-
if ($reference->getType() === $type) {
291-
$variance = $reference->getPositionVariance();
292-
break;
293-
}
294-
}
311+
if ($type->getName() !== $keepInferredName) {
312+
$newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType);
313+
}
295314

296-
$callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName());
297-
if ($callSiteVariance === null || $callSiteVariance->invariant()) {
298-
return $newType;
315+
$variance = TemplateTypeVariance::createInvariant();
316+
foreach ($references as $reference) {
317+
// this uses identity to distinguish between different occurrences of the same template type
318+
// see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details
319+
if ($reference->getType() === $type) {
320+
$variance = $reference->getPositionVariance();
321+
break;
299322
}
323+
}
300324

301-
if (!$callSiteVariance->covariant() && $variance->covariant()) {
302-
return $traverse($type->getBound());
303-
}
325+
$callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName());
326+
if ($callSiteVariance === null || $callSiteVariance->invariant()) {
327+
return $newType;
328+
}
304329

305-
if (!$callSiteVariance->contravariant() && $variance->contravariant()) {
306-
return new NonAcceptingNeverType();
307-
}
330+
if (!$callSiteVariance->covariant() && $variance->covariant()) {
331+
return $traverse($type->getBound());
332+
}
308333

309-
return $newType;
334+
if (!$callSiteVariance->contravariant() && $variance->contravariant()) {
335+
return new NonAcceptingNeverType();
310336
}
311337

312-
return $traverse($type);
313-
});
338+
return $newType;
339+
}
340+
341+
return $traverse($type);
314342
}
315343

316344
/**
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace NarrowedSubjectInferredLiteral;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
class User
10+
{
11+
12+
}
13+
14+
enum Digit: int
15+
{
16+
17+
case One = 1;
18+
19+
}
20+
21+
/**
22+
* @template TKey of array-key
23+
* @template TValue
24+
*/
25+
class Collection
26+
{
27+
28+
/**
29+
* @template TNewKey of array-key|\UnitEnum
30+
*
31+
* @param (callable(TValue, TKey): TNewKey)|array<mixed>|string $keyBy
32+
* @return static<($keyBy is (array|string) ? array-key : (TNewKey is \UnitEnum ? array-key : TNewKey)), TValue>
33+
*/
34+
public function keyBy($keyBy)
35+
{
36+
throw new \Exception();
37+
}
38+
39+
/**
40+
* @template TNewKey of array-key|\UnitEnum
41+
*
42+
* @param callable(TValue, TKey): TNewKey $keyBy
43+
* @return static<(TNewKey is \UnitEnum ? array-key : TNewKey), TValue>
44+
*/
45+
public function keyByCallback($keyBy)
46+
{
47+
throw new \Exception();
48+
}
49+
50+
}
51+
52+
/**
53+
* @template T of array-key
54+
* @template U of array-key
55+
* @param T $a
56+
* @param U $b
57+
* @return Collection<(T is U ? T : int), int>
58+
*/
59+
function templateTarget($a, $b)
60+
{
61+
throw new \Exception();
62+
}
63+
64+
/**
65+
* @template T of array-key
66+
* @template U of array-key
67+
* @param T $a
68+
* @param U $b
69+
* @return Collection<(T is not U ? int : T), int>
70+
*/
71+
function negatedTemplateTarget($a, $b)
72+
{
73+
throw new \Exception();
74+
}
75+
76+
/** @param Collection<int, User> $collection */
77+
function test(Collection $collection): void
78+
{
79+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyBy(fn ($user) => 'foo'));
80+
assertType('NarrowedSubjectInferredLiteral\\Collection<0, NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user): int => 0));
81+
assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy('name'));
82+
assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user) => Digit::One));
83+
84+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyByCallback(fn ($user) => 'foo'));
85+
assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyByCallback(static fn ($user) => Digit::One));
86+
87+
// the condition compares the generalized T and U, the branch keeps the inferred T
88+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', int>", templateTarget('foo', 'bar'));
89+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', int>", negatedTemplateTarget('foo', 'bar'));
90+
}

0 commit comments

Comments
 (0)