Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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
  • Loading branch information
ondrejmirtes and claude committed Sep 25, 2026
commit c2434bcd427e08aba5d023a59105c82f0430d536
33 changes: 26 additions & 7 deletions src/Type/ConditionalTypeForParameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,40 @@ public static function resolveInType(Type $type, callable $getSubjectType): Type
if ($type instanceof self) {
$subjectType = $getSubjectType($type->getParameterName());
if ($subjectType !== null) {
// Traverse children first, then convert — avoids infinite loop when
// Resolve the branches first, then convert — avoids infinite loop when
// the subject contains a ConditionalTypeForParameter with a colliding parameter name.
$type = $traverse($type);
if ($type instanceof self) {
return $type->toConditional($subjectType);
}

return $type;
return $type->resolveWithSubject($subjectType, $getSubjectType);
}
}

return $traverse($type);
});
}

/**
* A condition on the same parameter nested in a branch sees the subject as the branch
* knows it: `subject & target` where this condition holds, `subject ~ target` where it
* does not. `($key is Model ? A : ($key is Arrayable ? B : C))` asks whether the
* non-Model part of the argument is Arrayable.
*
* @param callable(string): ?Type $getSubjectType
*/
private function resolveWithSubject(Type $subject, callable $getSubjectType): Type
{
$target = self::resolveInType($this->target, $getSubjectType);
$getNarrowedSubjectType = fn (bool $conditionHolds): callable => fn (string $parameterName): ?Type => $parameterName === $this->parameterName
? ($conditionHolds ? TypeCombinator::intersect($subject, $target) : TypeCombinator::remove($subject, $target))
: $getSubjectType($parameterName);

return new ConditionalType(
$subject,
$target,
self::resolveInType($this->getNormalizedIf(), $getNarrowedSubjectType(!$this->negated)),
self::resolveInType($this->getNormalizedElse(), $getNarrowedSubjectType($this->negated)),
$this->negated,
);
}

public function toConditional(Type $subject): Type
{
return new ConditionalType(
Expand Down
67 changes: 67 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15296.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15296;

use function PHPStan\Testing\assertType;

interface Arrayable {}
class Model implements Arrayable {}
final class User extends Model {}

/** @template TModel of Model */
final class Collection
{
/**
* @return ($key is Model ? TModel|null : ($key is (Arrayable|array<mixed>) ? static : TModel|null))
*/
public function find(mixed $key): mixed
{
return null;
}
}

/** @param Collection<User> $collection */
function test(Collection $collection, ?User $user): void
{
assertType('Bug15296\User|null', $collection->find($user));
}

class Variants
{

/**
* @return ($key is not Model ? ($key is Arrayable ? 'arrayable' : 'other') : 'model')
*/
public function negated(mixed $key): string
{
return 'other';
}

/**
* @return ($key is Model ? ($key is User ? 'user' : 'model') : 'other')
*/
public function inIf(mixed $key): string
{
return 'other';
}

/**
* @return ($key is User ? 'user' : ($key is Model ? 'model' : ($key is null ? 'null' : 'other')))
*/
public function deep(mixed $key): string
{
return 'other';
}

}

function testVariants(Variants $v, ?User $user, Model|int $modelOrInt, User|Model|null $any): void
{
assertType("'model'|'other'", $v->negated($modelOrInt));
assertType("'model'", $v->negated($user ?? new User()));
assertType("'other'|'user'", $v->inIf($user));
assertType("'model'|'other'|'user'", $v->inIf($modelOrInt));
assertType("'model'|'null'|'user'", $v->deep($any));
}
Loading