From 5054450f8ad22079d6057483693f093636c86332 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:45:48 +0000 Subject: [PATCH] Fix phpstan/phpstan#10786: Conditional type narrowing for property fetches in && conditions - Extended ConditionalExpressionHolder creation in TypeSpecifier to support property fetches, static property fetches, and array dim fetches (not just variables) - Fixed issetCheck for nullable native typed properties to fall through to type callback instead of returning undefined, enabling proper narrowing in ?? operator - New regression test in tests/PHPStan/Analyser/nsrt/bug-10786.php --- src/Analyser/MutatingScope.php | 17 ++++--- src/Analyser/TypeSpecifier.php | 58 ++++++----------------- tests/PHPStan/Analyser/nsrt/bug-10786.php | 36 ++++++++++++++ 3 files changed, 61 insertions(+), 50 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10786.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a43ba35dda5..4ee8b68897e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1068,15 +1068,20 @@ public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = n if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { if (!$this->hasExpressionType($expr)->yes()) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } + if ($propertyReflection->getWritableType()->isNull()->no()) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; } - return null; + // Nullable native property: fall through to type callback + // so that isset() falsey properly narrows to null } } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c433..d25df60fa44 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1917,10 +1917,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes { $conditionExpressionTypes = []; foreach ($leftTypes->getSureTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->canBeConditionalExpressionHolder($expr)) { continue; } @@ -1933,10 +1930,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes if (count($conditionExpressionTypes) > 0) { $holders = []; foreach ($rightTypes->getSureTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->canBeConditionalExpressionHolder($expr)) { continue; } @@ -1945,20 +1939,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes } $conditions = $conditionExpressionTypes; - foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { - $conditionExpr = $conditionExprTypeHolder->getExpr(); - if (!$conditionExpr instanceof Expr\Variable) { - continue; - } - if (!is_string($conditionExpr->name)) { - continue; - } - if ($conditionExpr->name !== $expr->name) { - continue; - } - - unset($conditions[$conditionExprString]); - } + unset($conditions[$exprString]); if (count($conditions) === 0) { continue; @@ -2038,10 +2019,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy { $conditionExpressionTypes = []; foreach ($leftTypes->getSureNotTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->canBeConditionalExpressionHolder($expr)) { continue; } @@ -2054,10 +2032,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy if (count($conditionExpressionTypes) > 0) { $holders = []; foreach ($rightTypes->getSureNotTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->canBeConditionalExpressionHolder($expr)) { continue; } @@ -2066,20 +2041,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy } $conditions = $conditionExpressionTypes; - foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { - $conditionExpr = $conditionExprTypeHolder->getExpr(); - if (!$conditionExpr instanceof Expr\Variable) { - continue; - } - if (!is_string($conditionExpr->name)) { - continue; - } - if ($conditionExpr->name !== $expr->name) { - continue; - } - - unset($conditions[$conditionExprString]); - } + unset($conditions[$exprString]); if (count($conditions) === 0) { continue; @@ -2098,6 +2060,14 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy return []; } + private function canBeConditionalExpressionHolder(Expr $expr): bool + { + return $expr instanceof Expr\Variable + || $expr instanceof Expr\PropertyFetch + || $expr instanceof Expr\StaticPropertyFetch + || $expr instanceof Expr\ArrayDimFetch; + } + /** * @return array{Expr, ConstantScalarType, Type}|null */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-10786.php b/tests/PHPStan/Analyser/nsrt/bug-10786.php new file mode 100644 index 00000000000..db040bb624e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10786.php @@ -0,0 +1,36 @@ +value) && is_null($b->value)) { + throw new \Exception(); + } + + assertType('int', $a->value ?? $b->value); + + return $a->value ?? $b->value; + } + + public function sayHello2(Value $a, Value $b): int + { + if ($a->value === null && $b->value === null) { + throw new \Exception(); + } + + assertType('int|null', $a->value); + assertType('int|null', $b->value); + assertType('int', $a->value ?? $b->value); + + return $a->value ?? $b->value; + } +}