Do not treat conditional-expression entries about a property fetch as proof it is initialized - #6183
Merged
staabm merged 1 commit intoAug 6, 2026
Conversation
… proof it is initialized - `IssetabilityResolution::isSet()` bailed out of the "typed property may be uninitialized" branch whenever the scope held conditional-expression entries about the property fetch, on the assumption that such entries only come from reading the fetch inside an evaluated condition. They are also created by `AssignHandler` when a property is assigned inside a conditional branch, so a conditionally initialized property was reported as always set. - Dropped the `hasConditionalExpressionsOfFetch` clause from `IssetabilityResolution::isSet()`, restoring exact parity with `MutatingScope::issetCheck()` and `Rules\IssetCheck::doCheck()`, neither of which ever consulted conditional expressions. - Removed the now-unused `hasConditionalExpressionsOfFetch` plumbing from `IssetabilityLinkInfo` and its computation in `IssetabilityDescriptor`. - The scenario the clause was meant to support (property read inside a condition) keeps working, because reading a property leaves a real expression type in the scope, which `hasExpressionTypeOfFetch()` already covers. - Same root cause reproduced and fixed for the analogous constructs: static properties, plain boolean conditions, `??=`, ternary assignment, `switch` branches and nested `$this->inner->deep` chains. Probed and found already correct: `isset()`/`empty()` (they go through `Rules\IssetCheck`, which lacks the clause), the inferred types of `??`/`isset()` (computed by `MutatingScope::issetCheck()`), assignments inside `try`/`catch` and loops, and hooked/promoted-readonly properties.
staabm
approved these changes
Aug 6, 2026
VincentLanglet
approved these changes
Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
$this->foo ?? nullwas reported asnullCoalesce.unnecessary("Coalesce operator??is unnecessary because the left side is always set and the right side is null.") when the typed property$this->foohad only been conditionally initialised earlier in the same scope — even though it may still be uninitialised at that point.The check that decides whether the left side of
??is always set now no longer treats the mere presence of conditional-expression entries about a property fetch as proof that the property is initialised.Changes
src/Analyser/IssetabilityResolution.php— removed the!$link->hasConditionalExpressionsOfFetch()clause from the property branch ofisSet(), restoring exact parity withMutatingScope::issetCheck().src/Analyser/IssetabilityLinkInfo.php— removed the now-unusedhasConditionalExpressionsOfFetchconstructor parameter, factory argument and getter.src/Analyser/IssetabilityDescriptor.php— stopped computingisset($scope->getConditionalExpressions()[$scope->getNodeKey($propertyFetch)]).tests/PHPStan/Rules/Variables/data/bug-15046.php,tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php— regression test.Analogous cases that reproduced the same false positive and are fixed by the same change (all covered by the new test data file):
self::$answer ?? null)boolcondition instead of a!== nullnarrowing??=($this->answer ??= null)switchbranch$this->inner->deep ?? null)Probed and found already correct, so no test was kept for them:
isset()andempty()on a conditionally initialised property — these render throughRules\IssetCheck::doCheck(), which never consulted conditional expressions.$this->answer ?? null,isset($this->answer)andempty($this->answer)— computed byMutatingScope::issetCheck(), which also never consulted conditional expressions.try/catch,whileandfor— these do not produce conditional-expression entries.Root cause
IssetabilityResolution::isSet()is the fold thatNullCoalesceRule::checkUnnecessaryNullCoalesce()uses to decide whether the left side of??is always set. Its property branch normally answers "maybe" for a typed property with no default value, because such a property can be uninitialised. That branch was skipped whenever$scope->getConditionalExpressions()held entries keyed by the property fetch, documented with the rationale that such entries "exist only when the fetch was narrowed in an evaluated condition — and evaluating a condition READS the fetch, which would have thrown on an uninitialized typed property".That premise is wrong.
AssignHandleralso records conditional expressions for a property that is assigned inside a conditional branch, so:produced exactly the entries the clause interpreted as "witnessed initialisation", and the fold answered "always set".
The clause was introduced in
ac92f19c8("Resolve isset/empty/?? chains from handler-built descriptors", released in 2.2.8) whenMutatingScope::issetCheck()'s chain walk was reimplemented on top ofIssetabilityDescriptor; neitherMutatingScope::issetCheck()norRules\IssetCheckhas ever had it. Dropping it restores the three implementations to the same semantics. The scenario the clause was meant to cover still works: reading a property inside a condition puts a realExpressionTypeHolderfor the fetch into the scope, which the pre-existinghasExpressionTypeOfFetch()check already handles.Test
tests/PHPStan/Rules/Variables/data/bug-15046.phpcontains the reporter's playground snippet verbatim plus the analogous constructs listed above, and two control cases that must keep reporting: a property assigned in both branches of anif/else(nullCoalesce.initializedProperty) and a fetch inside a secondifwhere the same condition holds again (nullCoalesce.property).Without the fix the test fails with seven
nullCoalesce.unnecessaryfalse positives (lines 14, 34, 50, 66, 80, 98, 122); with the fix only the two control errors remain.Full
make testsandmake phpstanare green.Fixes phpstan/phpstan#15046