feat: support nullable and never types in @param-closure-this#6070
Open
calebdw wants to merge 1 commit into
Open
feat: support nullable and never types in @param-closure-this#6070calebdw wants to merge 1 commit into
calebdw wants to merge 1 commit into
Conversation
@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
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.
Problem
Laravel's Carbon library (and similar APIs) register macros that are invoked either bound to a model instance or statically, with no
$this:Previously there was no way to tell PHPStan about the optional-binding case:
@param-closure-thisonly accepted concrete object types. Specifying?Foosilently stripped thenull(via intersection withObjectWithoutClassType), so PHPStan always treated$thisas definitely bound, makingisset($this)always-true noise.Solution
@param-closure-this ?Foo $cb— optionally bound$thisis propagated into the closure with Maybe certainty.isset($this)narrows toFooin the truthy branch. Accessing$thiswithout the guard raises "Variable $this might not be defined".@param-closure-this never $cb— always unbound$thisinside the body has typenever. Any property/method access is a type error.This matches the PHP runtime behaviour of explicitly unbinding a closure:
(
nullis also accepted as a synonym fornever—TypeCombinator::removeNull(NullType)producesNeverType— so@param-closure-this null $cbproduces identical analysis.)Implementation
PhpDocNodeResolver: For nullable union types (?Foo), strip null, intersect the object part withObjectWithoutClassType, then re-addnullas an optional-binding signal that threads through the parameter reflection chain unchanged.NodeScopeResolver: Three-way dispatch on the resolvedclosureThisType:withoutThis()removes$thisfrom scope entirelyassignVariablewithTrinaryLogic::createMaybe()TrinaryLogic::createYes()path (no behavior change)MutatingScope::withoutThis(): New helper that removes$thisfrom the expression-types map for the always-unbound case.MutatingScope::enterAnonymousFunctionWithoutReflection: Changed fromhasVariableType('this')->yes()toisset(expressionTypes['$this'])so that$thiswith eitherYesorMaybecertainty is propagated into the closure scope, enablingisset($this)narrowing through the standard variable-certainty path.