Skip to content

Introduce ClosureType::isStaticClosure()#5699

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

Introduce ClosureType::isStaticClosure()#5699
phpstan-bot wants to merge 6 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-hguxjyq

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 18, 2026

Summary

Introduces isStaticClosure(): TrinaryLogic on ClosureType and CallableParametersAcceptor to track whether a closure is declared with the static keyword. This enables PHPStan to distinguish static from non-static closures in the type system, which is useful for rules that check closure binding compatibility and for narrowing the return types of Closure::bindTo() and Closure::bind().

Changes

Core type system

  • Added isStatic parameter (defaulting to Maybe) to ClosureType constructor (src/Type/ClosureType.php)
  • Added isStaticClosure(): TrinaryLogic method to ClosureType
  • Updated ClosureType::equals() to compare isStatic
  • Updated ClosureType::describe() to show static prefix at precise level and static/non-static at cache level
  • Updated ClosureType::traverse() and traverseSimultaneously() to preserve isStatic

Interface and implementations

  • Added isStaticClosure(): TrinaryLogic to CallableParametersAcceptor interface (src/Reflection/Callables/CallableParametersAcceptor.php)
  • Implemented in all concrete classes:
    • CallableType — returns Maybe (callable could be anything)
    • TrivialParametersAcceptor — returns Maybe
    • FunctionCallableVariant — returns No (named functions are not closures)
    • InaccessibleMethod — returns No
    • ExtendedCallableFunctionVariant — propagated via constructor parameter
    • ResolvedFunctionVariantWithCallable — propagated via constructor parameter

Closure creation

  • Updated ClosureTypeResolver to pass TrinaryLogic::createFromBoolean($expr->static) at all three ClosureType construction sites (src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php)
  • Updated InitializerExprTypeResolver to set isStatic: TrinaryLogic::createYes() for static closures in constant contexts (src/Reflection/InitializerExprTypeResolver.php)

Propagation

  • ParametersAcceptorSelector — propagates isStaticClosure when combining/wrapping callable variants (src/Reflection/ParametersAcceptorSelector.php)
  • GenericParametersAcceptorResolver — propagates through template resolution (src/Reflection/GenericParametersAcceptorResolver.php)
  • ClosureFromCallableDynamicReturnTypeExtension — propagates from variant to new ClosureType
  • TypeNodeResolver — propagates when overriding ClosureType with PHPDoc
  • RuleLevelHelper — propagates when transforming closure types
  • CallableTypeHelper::isParametersAcceptorSuperTypeOf() — checks isStaticClosure compatibility

Root cause

The issue requested tracking whether a closure is static in the type system. Previously, the static keyword on closures was only tracked at the AST level ($expr->static) and used for scope resolution (preventing $this access), but was not represented in ClosureType. This meant rules and return type extensions that needed to know if a closure was static had to inspect the AST node directly rather than querying the type.

The fix adds isStatic: TrinaryLogic to ClosureType and propagates it through all relevant interfaces and creation sites. The TrinaryLogic three-valued logic (yes/no/maybe) is used because closures from PHPDoc annotations or generic type resolution may have unknown static-ness.

Test

  • Type inference test (tests/PHPStan/Analyser/nsrt/closure-static-type.php): Verifies static prefix in type descriptions for static closures and arrow functions, verifies Closure::bindTo() and Closure::bind() return null for static closures and Closure for non-static closures, and verifies Closure|null for closures with unknown static-ness.
  • Unit tests (tests/PHPStan/Type/ClosureTypeTest.php): Tests for isSuperTypeOf() with static/non-static/maybe-static closure combinations, equals() comparisons, describe() output at all verbosity levels, and isStaticClosure() method.
  • Updated existing tests: degrade-closures.php, bug-7031.php, bug-9764.php, bug-14324.php — updated to expect static prefix for static closures. bug-4734 test updated to expect the new error from calling null returned by Closure::bind() on a static closure.

Fixes phpstan/phpstan#14639

…o()`/`Closure::bind()` return type for static closures

- Add `isStaticClosure(): TrinaryLogic` to `CallableParametersAcceptor` interface
- Add `isStatic` parameter to `ClosureType` constructor, propagated through
  `equals()`, `describe()`, `traverse()`, `traverseSimultaneously()`
- Show `static` prefix in `describe()` at precise level when closure is
  definitely static; show `non-static`/`static` at cache level when known
- Implement `isStaticClosure()` in all `CallableParametersAcceptor`
  implementations: `CallableType` (maybe), `TrivialParametersAcceptor` (maybe),
  `FunctionCallableVariant` (no), `InaccessibleMethod` (no),
  `ExtendedCallableFunctionVariant` and `ResolvedFunctionVariantWithCallable`
  (propagated from source)
- Set `isStatic` from `$expr->static` in `ClosureTypeResolver` for both
  `Closure` and `ArrowFunction` AST nodes
- Add `isStaticClosure` check to `CallableTypeHelper::isParametersAcceptorSuperTypeOf()`
- Propagate `isStaticClosure` through `ParametersAcceptorSelector`,
  `GenericParametersAcceptorResolver`, `ClosureFromCallableDynamicReturnTypeExtension`,
  `TypeNodeResolver`, and `RuleLevelHelper`
- Narrow `Closure::bindTo()` to return `null` for static closures and
  `Closure|null` for maybe-static closures
- Narrow `Closure::bind()` to return `null` for static closures and
  `Closure|null` for maybe-static closures
- Update existing tests to expect `static` prefix in closure type descriptions
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.

Remove all the change to ClosureBindDynamicReturnTypeExtension and ClosureBindToDynamicReturnTypeExtension.

This will be done in another PR

Comment thread src/Type/ClosureType.php Outdated
return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode());
},
function (): string {
$prefix = !$this->isStatic->maybe() ? ($this->isStatic->yes() ? 'static ' : 'non-static ') : '';
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.

There is no reason to use non-static here, we don't use non-pure.

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.

Both commits are pushed. Here's a summary of the changes:

Commit 1: Revert Closure::bindTo()/Closure::bind() return type narrowing

  • Reverted ClosureBindDynamicReturnTypeExtension and ClosureBindToDynamicReturnTypeExtension to their original state (no static closure checks)
  • Updated closure-static-type.php test assertions to expect the closure type (not null) from bind/bindTo
  • Reverted bug-4734 integration test to original 5-error expectation

Commit 2: Use hyphen prefix and extract helper method

  • Changed prefix from 'static ' to 'static-' (matching pure-Closure convention)
  • Removed non-static prefix at cache level (consistent with not using non-pure)
  • Extracted describeWithPrefix() private method, eliminating the duplicated closure body across value, precise, and cache callbacks (cache now falls through to precise)
  • Updated all test assertion strings from 'static Closure' to 'static-Closure'

Comment thread src/Type/ClosureType.php Outdated

return $printer->print($selfWithoutParameterNames->toPhpDocNode());
},
function (): string {
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.

Maybe a private methode could be created to refactor the value and precise level

Comment thread src/Type/ClosureType.php Outdated
return $printer->print($selfWithoutParameterNames->toPhpDocNode());
},
function (): string {
$prefix = $this->isStatic->yes() ? 'static ' : '';
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.

prefix should be with a -

phpstan-bot and others added 2 commits May 19, 2026 21:43
…r static closures

This will be done in a separate PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…` helper

- Change prefix from `static ` to `static-` to match the `pure-Closure` convention
- Remove `non-static` prefix at cache level (consistent with not using `non-pure`)
- Extract `describeWithPrefix()` private method to deduplicate value/precise callbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/Type/ClosureType.php Outdated
);
}

private function describeWithPrefix(string $prefix): string
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.

I prefer a method which take as param a bool to prefix pure and a bool to prefix static

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.

Pushed. Changed describeWithPrefix(string $prefix) to describeBody(bool $showPure, bool $showStatic) as requested — the method now takes two booleans that control whether to include the pure- and static- prefixes, rather than receiving a pre-computed string. Tests and static analysis both pass.

…ic` booleans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/Type/ClosureType.php Outdated
Comment on lines +283 to +284
fn (): string => $this->describeBody(showPure: true, showStatic: false),
fn (): string => $this->describeBody(showPure: true, showStatic: true),
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.

Do not use namedParam

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.

Done. Removed the named parameters from the describeBody() calls as requested by the reviewer. Tests and static analysis both pass.

@VincentLanglet VincentLanglet changed the title Introduce ClosureType::isStaticClosure() and narrow Closure::bindTo()/Closure::bind() return type for static closures Introduce ClosureType::isStaticClosure() May 19, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

Some AttributeReflectionTest::testAttributeReflections test are failing in PHP 8.5 because the expected output is static-Closure rather than Closure

…tributes

Static closures in PHP 8.5 attributes now correctly describe as
`static-Closure(...)` at precise verbosity level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Updated the 6 test expectations in AttributeReflectionTest where static closures in PHP 8.5 attributes now describe as static-Closure(...) instead of Closure(...) at precise verbosity level. All tests and static analysis pass.

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