Skip to content

Keep narrowing of $this-rooted expressions in immediately invoked closures - #6333

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-v27kjjq
Open

Keep narrowing of $this-rooted expressions in immediately invoked closures#6333
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-v27kjjq

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Narrowed types of $this->property were 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-callable on a @phpstan-pure method. 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.php
    • enterAnonymousFunction() and enterAnonymousFunctionWithoutReflection() accept a new optional bool $immediatelyInvoked. Combined with the ImmediatelyInvokedClosureVisitor attribute (IIFEs), it decides whether $this counts as a by-value capture, which makes every expression rooted in $this survive into the closure body.
    • New isExprCapturedByClosure() helper; the conditional-expression filter uses it instead of matching expression strings against use variable names.
    • Expressions carried into the closure scope now also carry their entry from nativeExpressionTypes, both in the general loop and in the readonly-property branch.
  • src/Analyser/NodeScopeResolver.php
    • processClosureNode() accepts and forwards $immediatelyInvoked; the call-args handler passes the callCallbackImmediately() 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):

  • Nested property fetches ($this->nested->prop) and pure method calls ($this->getFoo()), not just plain property fetches.
  • Callbacks passed to built-in functions with callable parameters (array_map) — same code path as @param-immediately-invoked-callable.
  • Conditional expression holders whose condition is rooted in $this.
  • Conditional expression holders rooted in a by-value used variable ($other->prop) — these were dropped before even though the plain narrowed type of $other->prop was kept, so the two disagreed.
  • Native types of all expressions carried into a closure scope.

Analogous cases probed and found already correct, so no change was made:

  • Arrow functions — they keep the whole outer scope, which is what the issue asks closures to match.
  • static function () {} / static fn () =>$this is correctly unavailable.
  • Static properties and static calls (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 that nsrt/bug-14478.php and nsrt/specified-types-closure-edge.php deliberately pin down.

Root cause

MutatingScope::enterAnonymousFunctionWithoutReflection() rebuilds the scope for a closure body from scratch. It keeps an expression's narrowed type only when every Variable inside that expression is a by-value use, which is why $other->prop survived a use ($other) but $this->prop never did — $this is 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.

$this is captured by value exactly like a use ($other) — what differs is when the body runs, so that is what the fix keys on: for a closure known to be invoked immediately, $this joins 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 use names (so anything but a bare $y was dropped), and the preservation loop wrote to expressionTypes only, never to nativeExpressionTypes.

Test

tests/PHPStan/Analyser/nsrt/bug-12912.php reproduces the playground samples from the issue (both the plain callback and the @phpstan-pure + @param-immediately-invoked-callable variant that Ondrej pointed at) and adds the analogous cases: IIFE, array_map callback, nested property fetch, pure method call, conditional expressions rooted in $this and in a used 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.php was updated for the IIFE case as described above.

Fixes phpstan/phpstan#12912

…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.
$nativeTypes[$paramExprString] = ExpressionTypeHolder::createYes($parameter->var, $nativeParameterType);
}

$capturesThis = $this->hasVariableType('this')->yes() && !$closure->static;

@staabm staabm Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cheap !$closure->static check first

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll report back when the suite completes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Difference between Arrow function and Closure

3 participants