Skip to content

Commit 51b4d48

Browse files
ondrejmirtesclaude
andcommitted
Narrow the parameter for nested conditions on the same parameter
In `($key is Model ? A : ($key is Arrayable ? B : C))` the inner condition asks about the part of the argument that is not a Model. That narrowing used to happen only because both conditions were resolved against the same argument type object, which the enclosing ConditionalType matched by identity - gone since branches are narrowed through NarrowedSubjectType. ConditionalTypeForParameter::resolveInType() now resolves the parameter as `subject & target` inside the if branch and `subject ~ target` inside the else branch, recognising the nested conditions by the parameter name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uob4ZH23SzXKt3uM5tDUrQ
1 parent 8411207 commit 51b4d48

2 files changed

Lines changed: 93 additions & 7 deletions

File tree

‎src/Type/ConditionalTypeForParameter.php‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,21 +115,40 @@ public static function resolveInType(Type $type, callable $getSubjectType): Type
115115
if ($type instanceof self) {
116116
$subjectType = $getSubjectType($type->getParameterName());
117117
if ($subjectType !== null) {
118-
// Traverse children first, then convert — avoids infinite loop when
118+
// Resolve the branches first, then convert — avoids infinite loop when
119119
// the subject contains a ConditionalTypeForParameter with a colliding parameter name.
120-
$type = $traverse($type);
121-
if ($type instanceof self) {
122-
return $type->toConditional($subjectType);
123-
}
124-
125-
return $type;
120+
return $type->resolveWithSubject($subjectType, $getSubjectType);
126121
}
127122
}
128123

129124
return $traverse($type);
130125
});
131126
}
132127

128+
/**
129+
* A condition on the same parameter nested in a branch sees the subject as the branch
130+
* knows it: `subject & target` where this condition holds, `subject ~ target` where it
131+
* does not. `($key is Model ? A : ($key is Arrayable ? B : C))` asks whether the
132+
* non-Model part of the argument is Arrayable.
133+
*
134+
* @param callable(string): ?Type $getSubjectType
135+
*/
136+
private function resolveWithSubject(Type $subject, callable $getSubjectType): Type
137+
{
138+
$target = self::resolveInType($this->target, $getSubjectType);
139+
$getNarrowedSubjectType = fn (bool $conditionHolds): callable => fn (string $parameterName): ?Type => $parameterName === $this->parameterName
140+
? ($conditionHolds ? TypeCombinator::intersect($subject, $target) : TypeCombinator::remove($subject, $target))
141+
: $getSubjectType($parameterName);
142+
143+
return new ConditionalType(
144+
$subject,
145+
$target,
146+
self::resolveInType($this->getNormalizedIf(), $getNarrowedSubjectType(!$this->negated)),
147+
self::resolveInType($this->getNormalizedElse(), $getNarrowedSubjectType($this->negated)),
148+
$this->negated,
149+
);
150+
}
151+
133152
public function toConditional(Type $subject): Type
134153
{
135154
return new ConditionalType(
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15296;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
interface Arrayable {}
10+
class Model implements Arrayable {}
11+
final class User extends Model {}
12+
13+
/** @template TModel of Model */
14+
final class Collection
15+
{
16+
/**
17+
* @return ($key is Model ? TModel|null : ($key is (Arrayable|array<mixed>) ? static : TModel|null))
18+
*/
19+
public function find(mixed $key): mixed
20+
{
21+
return null;
22+
}
23+
}
24+
25+
/** @param Collection<User> $collection */
26+
function test(Collection $collection, ?User $user): void
27+
{
28+
assertType('Bug15296\User|null', $collection->find($user));
29+
}
30+
31+
class Variants
32+
{
33+
34+
/**
35+
* @return ($key is not Model ? ($key is Arrayable ? 'arrayable' : 'other') : 'model')
36+
*/
37+
public function negated(mixed $key): string
38+
{
39+
return 'other';
40+
}
41+
42+
/**
43+
* @return ($key is Model ? ($key is User ? 'user' : 'model') : 'other')
44+
*/
45+
public function inIf(mixed $key): string
46+
{
47+
return 'other';
48+
}
49+
50+
/**
51+
* @return ($key is User ? 'user' : ($key is Model ? 'model' : ($key is null ? 'null' : 'other')))
52+
*/
53+
public function deep(mixed $key): string
54+
{
55+
return 'other';
56+
}
57+
58+
}
59+
60+
function testVariants(Variants $v, ?User $user, Model|int $modelOrInt, User|Model|null $any): void
61+
{
62+
assertType("'model'|'other'", $v->negated($modelOrInt));
63+
assertType("'model'", $v->negated($user ?? new User()));
64+
assertType("'other'|'user'", $v->inIf($user));
65+
assertType("'model'|'other'|'user'", $v->inIf($modelOrInt));
66+
assertType("'model'|'null'|'user'", $v->deep($any));
67+
}

0 commit comments

Comments
 (0)