Skip to content

Commit 435aada

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 435aada

2 files changed

Lines changed: 112 additions & 2 deletions

File tree

‎src/Reflection/ResolvedFunctionVariantWithOriginal.php‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,23 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
230230
{
231231
$references = $type->getReferencedTemplateTypes($positionVariance);
232232

233-
$objectCb = function (Type $type, callable $traverse) use ($references): Type {
233+
$resolve = function (Type $type, callable $traverse, ?string $keepInferredName) use ($references, &$resolve): Type {
234+
if (
235+
$type instanceof NarrowedSubjectType
236+
&& $keepInferredName === null
237+
&& !$type->isArgument()
238+
&& $type->getScope()->getFunctionName() !== null
239+
) {
240+
// generalize what the branch knows about the subject, not the subject: in the
241+
// else branch of `(T is \UnitEnum ? array-key : T)` with T of array-key|\UnitEnum,
242+
// T is a scalar and an inferred 'foo' stays 'foo'. Only the subject is taken as
243+
// inferred - a template type in the target is generalized as the condition sees it.
244+
$subjectName = $type->getName();
245+
$resolved = TypeTraverser::map($type, static fn (Type $type, callable $traverse): Type => $resolve($type, $traverse, $subjectName));
246+
247+
return TemplateTypeHelper::generalizeInferredTemplateType($type, $resolved);
248+
}
249+
234250
if (
235251
$type instanceof TemplateType
236252
&& !$type instanceof NarrowedSubjectType
@@ -242,7 +258,10 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
242258
return $traverse($type);
243259
}
244260

245-
$newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType);
261+
if ($type->getName() !== $keepInferredName) {
262+
$newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType);
263+
}
264+
246265
$variance = TemplateTypeVariance::createInvariant();
247266
foreach ($references as $reference) {
248267
// this uses identity to distinguish between different occurrences of the same template type
@@ -271,6 +290,7 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
271290

272291
return $traverse($type);
273292
};
293+
$objectCb = static fn (Type $type, callable $traverse): Type => $resolve($type, $traverse, null);
274294

275295
return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type {
276296
if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) {
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)