Include null in return type of Closure::bindTo() and Closure::bind() dynamic return type extensions#5675
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
Given the fact it's __benevolent<Closure|null> the dynamic return type should not lose the benevolentUnion
|
Done. The change addresses VincentLanglet's review by using |
VincentLanglet
left a comment
There was a problem hiding this comment.
Is it possible to exclude null in the return type when we're sure it cannot be returned ?
|
Pushed. Here's a summary of the change: When When Fully excluding |
VincentLanglet
left a comment
There was a problem hiding this comment.
Fully excluding null for non-static closures with non-null $newThis would require tracking static-ness in ClosureType, which is a larger change — ClosureType currently has no $isStatic property. That could be a follow-up improvement (adding an optional ?TrinaryLogic $isStatic parameter to the @api constructor would be BC-safe).
Try the implementation
|
Pushed successfully. Here's a summary of what was done: Added Changes:
|
2a8f6c8 to
d50d5d9
Compare
252f429 to
e76d963
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
fix failing tests
|
The review asked to "fix failing tests." Done. Here's what was failing and why. Root causeThe PR's last commit (
Three test data files had stale expectations:
Verification
Committed as |
VincentLanglet
left a comment
There was a problem hiding this comment.
I think the logic should be
$args = $methodCall->getArgs();
if (!isset($args[0])) {
return null;
}
$closureType = $scope->getType($args[0]->value);
if (!($closureType instanceof ClosureType)) {
return null;
}
if ($closureType->isStaticClosure()->yes()) {
$newThisIsNull = !isset($args[1]) || $scope->getType($args[1]->value)->isNull()->yes();
return $newThisIsNull ? $closureType : new NullType();
}
return new BenevolentUnionType([$closureType, new NullType()]);
|
Done. I implemented the reviewer's suggested logic restructure and pushed it. What changed
if ($closureType->isStaticClosure()->yes()) {
$newThisIsNull = !isset($args[N]) || $scope->getType($args[N]->value)->isNull()->yes();
return $newThisIsNull ? $closureType : new NullType();
}
return new BenevolentUnionType([$closureType, new NullType()]);The behavioral difference from the previous version:
Test updates (all driven by actual analyser output, not guesses)
Verification
Committed as One note for the reviewer's awareness: with this logic, |
| $newThisIsNull = !isset($args[1]) || $scope->getType($args[1]->value)->isNull()->yes(); | ||
|
|
||
| return $newThisIsNull ? $closureType : new NullType(); |
There was a problem hiding this comment.
Handle the case where isNull return maybe, the value should be
new BenevolentUnionType([$closureType, new NullType()])
There was a problem hiding this comment.
Done. I addressed VincentLanglet's review comment about the maybe-null case.
What changed
In both ClosureBindDynamicReturnTypeExtension and ClosureBindToDynamicReturnTypeExtension, the static-closure branch previously collapsed isNull() to a boolean via ->yes(), which lumped the maybe-null case in with the definitely-non-null case and returned a bare NullType. Now it dispatches on all three TrinaryLogic outcomes of $newThis's nullness:
if ($closureType->isStaticClosure()->yes()) {
$newThisIsNull = isset($args[N]) ? $scope->getType($args[N]->value)->isNull() : TrinaryLogic::createYes();
if ($newThisIsNull->yes()) {
return $closureType; // binding a static closure with null $newThis only changes scope
}
if ($newThisIsNull->no()) {
return new NullType(); // binding a static closure to an object always fails
}
return new BenevolentUnionType([$closureType, new NullType()]); // maybe-null → either outcome
}This fixes the precision loss I flagged in my earlier note: $static->bindTo($maybeNull) (where $maybeNull is stdClass|null) now infers (static-Closure(): void)|null instead of null.
Verification
NodeScopeResolverTest— 1648/1648 pass (updated thebug-5009.phpassertion for$staticBoundMaybe)AnalyserIntegrationTest+ClosureTypeTest— 233/233 passmake phpstan— no errors
Committed as a separate logical change and pushed to the PR branch.
staabm
left a comment
There was a problem hiding this comment.
we should have a test that we no longer have a error in https://phpstan.org/r/6cb58802-5290-42da-ac62-35f999f6ee8a
|
Done. I addressed VincentLanglet's review request. What the review asked for
That playground snippet binds a closure inside a constructor and coalesces the result: $this->callback = $callback->bindTo($this, $this) ?? $callback;Before the fix, What I did
Verification (followed the stash-revert protocol)
Committed as |
…nd()` dynamic return type extensions - ClosureBindToDynamicReturnTypeExtension now returns TypeCombinator::union($closureType, new NullType()) instead of just $closureType - ClosureBindDynamicReturnTypeExtension (for static Closure::bind()) gets the same fix - Updated existing tests that asserted the buggy non-nullable return type - Updated AnalyserIntegrationTest::testBug4734 to expect the 4 new "Trying to invoke ... null" errors Closes phpstan/phpstan#5009
…urn types The function signature map declares these as `__benevolent<Closure|null>`, so the dynamic return type extensions should preserve the benevolent union semantics instead of creating a strict union. This avoids false positives for code that invokes the result without null-checking, matching the behavior of the default signature map. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…is null When $newThis is null, bindTo()/bind() only changes the closure's scope without binding $this, which always succeeds. Null is only returned when binding a static closure with a non-null $newThis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…dTo()/bind() return type Add an optional `?TrinaryLogic $isStatic` parameter to ClosureType's constructor to track whether a closure was declared with the `static` keyword. ClosureTypeResolver now passes this from the AST node. The bind extensions use this to exclude null from the return type when the closure is known to be non-static (binding always succeeds for non-static closures regardless of $newThis). For static or possibly-static closures with non-null $newThis, the return type remains BenevolentUnionType(Closure|null) since PHP returns null when binding an object to a static closure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…king Reflect the corrected dynamic return types of Closure::bindTo()/bind(): non-static closures bind successfully (no null), while static and unknown-staticness closures bound to a non-null scope can fail and yield ((...)|null). Static closures also describe with the static- prefix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…osure binds to object For a static closure bound to a non-null $newThis, binding fails and PHP returns null, so the extension now returns NullType. Static closure bound to null returns the closure unchanged. All other (non-static / unknown-staticness) cases return BenevolentUnionType<Closure|null>. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…null Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verifies that binding a closure with ?? no longer reports a false-positive "Expression on left side of ?? is not nullable." error, since bindTo() now returns a benevolent Closure|null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1ab9e5a to
132bae2
Compare
|
looking at the CI error in it looks like we are missing some benevolent handling? created another followup phpstan/phpstan#14818 |
Summary
Closure::bindTo()andClosure::bind()can returnnullwhen binding fails (e.g., binding a static closure to an object, or binding to an internal class). The dynamic return type extensions for these methods were stripping thenullfrom the return type, causing PHPStan to miss potential null-pointer issues.Changes
src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php— returnTypeCombinator::union($closureType, new NullType())instead of just$closureTypesrc/Type/Php/ClosureBindDynamicReturnTypeExtension.php— same fix for the staticClosure::bind()methodtests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php— updated assertions to expect(Closure(...))|nulltests/PHPStan/Analyser/AnalyserIntegrationTest.php— updatedtestBug4734to expect 4 new "Trying to invoke (Closure(): void)|null but it might not be a callable" errors (these are correct new diagnostics)tests/PHPStan/Analyser/nsrt/bug-5009.php— new regression testRoot cause
Both
ClosureBindToDynamicReturnTypeExtensionandClosureBindDynamicReturnTypeExtensionreturned the bareClosureTypewhen the caller was a known closure, which preserved the closure's parameter/return type information but dropped thenullthat PHP's actual return type includes. The function signature map has__benevolent<Closure|null>as the default, but this was completely overridden by the extension returning a non-nullable type.Analogous cases probed
Closure::fromCallable()— always returnsClosure, never null. Not affected.Closure::call()— returns the closure's return value, not a closure. Not analogous.Test
tests/PHPStan/Analyser/nsrt/bug-5009.php— verifies thatbindTo()andbind()with various arguments all return(Closure(): void)|nulltests/PHPStan/Analyser/nsrt/closure-return-type-extensions.phpto assert the nullable returnFixes phpstan/phpstan#5009