Skip to content

feat: support nullable and never types in @param-closure-this#6070

Open
calebdw wants to merge 1 commit into
phpstan:2.2.xfrom
calebdw:calebdw/push-numtvruwvspr
Open

feat: support nullable and never types in @param-closure-this#6070
calebdw wants to merge 1 commit into
phpstan:2.2.xfrom
calebdw:calebdw/push-numtvruwvspr

Conversation

@calebdw

@calebdw calebdw commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Laravel's Carbon library (and similar APIs) register macros that are invoked either bound to a model instance or statically, with no $this:

// Carbon registers this macro; when called on an instance $this is Carbon,
// when called statically $this is unbound
Carbon::macro('humanDiff', function () {
    if (isset($this)) {
        return $this->diffForHumans(); // $this is Carbon here
    }
    return 'N/A';
});

Previously there was no way to tell PHPStan about the optional-binding case: @param-closure-this only accepted concrete object types. Specifying ?Foo silently stripped the null (via intersection with ObjectWithoutClassType), so PHPStan always treated $this as definitely bound, making isset($this) always-true noise.

Solution

@param-closure-this ?Foo $cb — optionally bound

$this is propagated into the closure with Maybe certainty. isset($this) narrows to Foo in the truthy branch. Accessing $this without the guard raises "Variable $this might not be defined".

class Collection {
    /**
     * @param-closure-this ?static $callback
     */
    public function tap(callable $callback): static { ... }
}

$collection->tap(function () {
    $this->method(); // ⚠ Variable $this might not be defined

    if (isset($this)) {
        $this->method(); // ✅ $this is Collection here
    }
});

@param-closure-this never $cb — always unbound

$this inside the body has type never. Any property/method access is a type error.

This matches the PHP runtime behaviour of explicitly unbinding a closure:

class Runner {
    /** @param-closure-this never $cb */
    public function run(callable $cb): void
    {
        // Explicitly strip $this before invoking — closure::bind with null
        // unbinds $this so it does not exist inside the callback at all.
        // isset($this) would return false at runtime.
        $unbound = Closure::bind($cb, null, static::class);
        $unbound();
    }
}

$runner->run(function () {
    $this->method(); // ✅ PHPStan error: $this is never — correctly models the unbound call
});

(null is also accepted as a synonym for neverTypeCombinator::removeNull(NullType) produces NeverType — so @param-closure-this null $cb produces identical analysis.)

Implementation

  • PhpDocNodeResolver: For nullable union types (?Foo), strip null, intersect the object part with ObjectWithoutClassType, then re-add null as an optional-binding signal that threads through the parameter reflection chain unchanged.
  • NodeScopeResolver: Three-way dispatch on the resolved closureThisType:
    • Definitely null/never → withoutThis() removes $this from scope entirely
    • Nullable union → assignVariable with TrinaryLogic::createMaybe()
    • Non-nullable → original TrinaryLogic::createYes() path (no behavior change)
  • MutatingScope::withoutThis(): New helper that removes $this from the expression-types map for the always-unbound case.
  • MutatingScope::enterAnonymousFunctionWithoutReflection: Changed from hasVariableType('this')->yes() to isset(expressionTypes['$this']) so that $this with either Yes or Maybe certainty is propagated into the closure scope, enabling isset($this) narrowing through the standard variable-certainty path.

@param-closure-this ?Foo $cb marks a closure parameter whose $this binding
is optional — the closure may be invoked bound or unbound. PHPStan propagates
$this into the closure body with Maybe certainty, so isset($this) correctly
narrows to Foo and accessing $this without the guard triggers a 'Variable
$this might not be defined' error.

@param-closure-this never $cb marks a closure that is always invoked
unbound. $this inside the body has type never, making any access a type
error.

Implementation:
- PhpDocNodeResolver: for nullable union types (?Foo), strip null, intersect
  the object part with ObjectWithoutClassType, then re-add null as an
  optional-binding signal that threads through the parameter reflection chain
- NodeScopeResolver: three-way dispatch on the resolved closureThisType —
  definitely null uses withoutThis(), nullable union uses assignVariable with
  Maybe certainty, non-nullable uses the original Yes certainty path
- MutatingScope::withoutThis(): removes $this from scope for the always-unbound
  case
- MutatingScope::enterAnonymousFunctionWithoutReflection: checks
  isset(expressionTypes['$this']) and propagates $this with its original
  certainty (Yes or Maybe) into the closure scope, enabling isset($this)
  narrowing through the standard variable-certainty path
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.

1 participant