Skip to content

Include null in return type of Closure::bindTo() and Closure::bind() dynamic return type extensions#5675

Open
phpstan-bot wants to merge 4 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-jnyy0bk
Open

Include null in return type of Closure::bindTo() and Closure::bind() dynamic return type extensions#5675
phpstan-bot wants to merge 4 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-jnyy0bk

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Closure::bindTo() and Closure::bind() can return null when 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 the null from the return type, causing PHPStan to miss potential null-pointer issues.

Changes

  • src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php — return TypeCombinator::union($closureType, new NullType()) instead of just $closureType
  • src/Type/Php/ClosureBindDynamicReturnTypeExtension.php — same fix for the static Closure::bind() method
  • tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php — updated assertions to expect (Closure(...))|null
  • tests/PHPStan/Analyser/AnalyserIntegrationTest.php — updated testBug4734 to 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 test

Root cause

Both ClosureBindToDynamicReturnTypeExtension and ClosureBindDynamicReturnTypeExtension returned the bare ClosureType when the caller was a known closure, which preserved the closure's parameter/return type information but dropped the null that 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 returns Closure, 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 that bindTo() and bind() with various arguments all return (Closure(): void)|null
  • Updated tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php to assert the nullable return

Fixes phpstan/phpstan#5009

…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
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Given the fact it's __benevolent<Closure|null> the dynamic return type should not lose the benevolentUnion

…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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The change addresses VincentLanglet's review by using BenevolentUnionType instead of a strict TypeCombinator::union() in both ClosureBindToDynamicReturnTypeExtension and ClosureBindDynamicReturnTypeExtension. This preserves the __benevolent<Closure|null> semantics from the function signature map, so code that calls bindTo()/bind() and immediately invokes the result won't get false-positive "might not be a callable" errors at lower rule levels. The bug-4734 integration test reverts to its original 5 errors (the 4 spurious "Trying to invoke" errors are gone).

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Is it possible to exclude null in the return type when we're sure it cannot be returned ?

…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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the change:

When $newThis is null, bindTo(null) / bind($closure, null) only changes the closure's scope — it never fails, so the return type is now just Closure (no null). This is true regardless of whether the closure is static.

When $newThis is non-null, binding can still fail if the closure is static (PHP returns null), so the return type remains BenevolentUnionType(Closure|null).

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).

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

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

…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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed successfully. Here's a summary of what was done:

Added isStatic tracking to ClosureType to exclude null from bindTo()/bind() return types when we can prove binding will succeed.

Changes:

  1. src/Type/ClosureType.php — Added ?TrinaryLogic $isStatic = null as the last constructor parameter (BC-safe since it's optional with null default). Added isStaticClosure(): TrinaryLogic getter. Preserved $isStatic through traverse(), traverseSimultaneously(), and describe().

  2. src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php — All three new ClosureType(...) calls now pass isStatic: TrinaryLogic::createFromBoolean($expr->static), capturing the static keyword from the PHP parser AST.

  3. src/Reflection/InitializerExprTypeResolver.php — The static closure path (already gated by $expr->static) now passes isStatic: TrinaryLogic::createYes().

  4. src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php and ClosureBindDynamicReturnTypeExtension.php — When $closureType->isStaticClosure()->no(), return just $closureType (no null). Non-static closures always bind successfully regardless of $newThis. Otherwise, fall through to the existing null-$newThis check and BenevolentUnionType logic.

  5. tests/PHPStan/Analyser/nsrt/bug-5009.php — Updated assertions: non-static closures now return Closure(): void (not nullable) even with non-null $newThis. Added test cases for Closure::bind() with static closures and $maybeNull with static closures.

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.

2 participants