Keep narrowing of $this-rooted expressions in immediately invoked closures - #6333
Open
phpstan-bot wants to merge 1 commit into
Open
Keep narrowing of $this-rooted expressions in immediately invoked closures#6333phpstan-bot wants to merge 1 commit into
$this-rooted expressions in immediately invoked closures#6333phpstan-bot wants to merge 1 commit into
Conversation
…losures - `MutatingScope::enterAnonymousFunction()`/`enterAnonymousFunctionWithoutReflection()` take a new `$immediatelyInvoked` flag; when the closure is invoked immediately (IIFE, or a `@param-immediately-invoked-callable`/callable parameter that `NodeScopeResolver::callCallbackImmediately()` reports as called right away), `$this` is treated as a by-value capture, so `$this->prop`, `$this->a->b`, `$this->method()` and array offsets on them keep the type they were narrowed to at the call site. - `NodeScopeResolver::processClosureNode()` passes the already computed `callCallbackImmediately()` result down to the closure scope. - Conditional expressions are no longer filtered by a plain `use`-name lookup: the new `MutatingScope::isExprCapturedByClosure()` keeps a holder when every variable its expressions are rooted in is captured. That also fixes conditional types rooted in a by-value `use`d object (`$other->prop`), which were dropped before regardless of invocation timing. - Expressions kept in a closure scope now carry their native type over as well, matching arrow functions; previously only the PHPDoc type survived, so `assertNativeType()` inside a closure saw the declared type. - Later-invoked closures keep the existing behaviour from phpstan/phpstan#13321: narrowing of writable properties is dropped, readonly ones are kept. - Probed and left alone: arrow functions (already keep the narrowing), static closures (correctly have no `$this`), and static properties/static calls, which are dropped identically by closures and arrow functions.
staabm
reviewed
Sep 1, 2026
| $nativeTypes[$paramExprString] = ExpressionTypeHolder::createYes($parameter->var, $nativeParameterType); | ||
| } | ||
|
|
||
| $capturesThis = $this->hasVariableType('this')->yes() && !$closure->static; |
Contributor
There was a problem hiding this comment.
Cheap !$closure->static check first
Collaborator
Author
There was a problem hiding this comment.
I'll report back when the suite completes.
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
Narrowed types of
$this->propertywere visible inside arrow functions but silently lost inside anonymous functions, even when the callback is provably invoked right away — an IIFE, or a parameter marked@param-immediately-invoked-callableon a@phpstan-puremethod. In that situation nothing can run between the narrowing and the callback body, so the closure now sees the same types as the surrounding scope.Narrowing is still dropped for closures that may be invoked later, which keeps the behaviour agreed on in phpstan/phpstan#13321 (the property can be reassigned before the callback runs).
Changes
src/Analyser/MutatingScope.phpenterAnonymousFunction()andenterAnonymousFunctionWithoutReflection()accept a new optionalbool $immediatelyInvoked. Combined with theImmediatelyInvokedClosureVisitorattribute (IIFEs), it decides whether$thiscounts as a by-value capture, which makes every expression rooted in$thissurvive into the closure body.isExprCapturedByClosure()helper; the conditional-expression filter uses it instead of matching expression strings againstusevariable names.nativeExpressionTypes, both in the general loop and in the readonly-property branch.src/Analyser/NodeScopeResolver.phpprocessClosureNode()accepts and forwards$immediatelyInvoked; the call-args handler passes thecallCallbackImmediately()result it already computes (reused for throw/impure points instead of computing it twice).tests/PHPStan/Analyser/nsrt/bug-13321.php— the IIFE block now asserts the narrowed type for the writable property too. The$test = function () {…}; $test();block still asserts the widened type, so the later-invoked contract stays covered.Analogous cases fixed with the same change (each has a test):
$this->nested->prop) and pure method calls ($this->getFoo()), not just plain property fetches.array_map) — same code path as@param-immediately-invoked-callable.$this.used variable ($other->prop) — these were dropped before even though the plain narrowed type of$other->propwas kept, so the two disagreed.Analogous cases probed and found already correct, so no change was made:
static function () {}/static fn () =>—$thisis correctly unavailable.self::$x,self::foo()) — dropped identically by closures and arrow functions, so they do not exhibit the reported difference. Preserving them for immediately invoked callbacks was tried and reverted: it also resurfaces outer-scope narrowing of class constants and static call results thatnsrt/bug-14478.phpandnsrt/specified-types-closure-edge.phpdeliberately pin down.Root cause
MutatingScope::enterAnonymousFunctionWithoutReflection()rebuilds the scope for a closure body from scratch. It keeps an expression's narrowed type only when everyVariableinside that expression is a by-valueuse, which is why$other->propsurvived ause ($other)but$this->propnever did —$thisis not in$closure->uses. The only exception was a special case for readonly property fetches on$this. Arrow functions take the opposite route and hand the whole outer scope over, hence the difference reported in the issue.$thisis captured by value exactly like ause ($other)— what differs is when the body runs, so that is what the fix keys on: for a closure known to be invoked immediately,$thisjoins the by-value capture set and all the existing expression-preservation logic applies to it unchanged.The same "root of the expression must be captured" rule was missing in two more places, which is what produced the parallel bugs: the conditional-expression filter compared raw expression strings against
usenames (so anything but a bare$ywas dropped), and the preservation loop wrote toexpressionTypesonly, never tonativeExpressionTypes.Test
tests/PHPStan/Analyser/nsrt/bug-12912.phpreproduces the playground samples from the issue (both the plain callback and the@phpstan-pure+@param-immediately-invoked-callablevariant that Ondrej pointed at) and adds the analogous cases: IIFE,array_mapcallback, nested property fetch, pure method call, conditional expressions rooted in$thisand in aused variable, native types, a later-invoked callback that must not narrow, and a static closure. Nine assertions in the file fail without the fix.tests/PHPStan/Analyser/nsrt/bug-13321.phpwas updated for the IIFE case as described above.Fixes phpstan/phpstan#12912