Skip to content

Infer signatures of closures from where their values are sent - #6604

Merged
ondrejmirtes merged 10 commits into
2.3.xfrom
closure-signatures-from-usages
Sep 27, 2026
Merged

ondrejmirtes merged 10 commits into
2.3.xfrom
closure-signatures-from-usages

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

A closure or arrow function written where nothing types it — assigned to a variable, put in an array — now gets its parameter types from the rest of the enclosing body, the way a closure passed straight to a callable parameter gets them from that parameter:

$c = function ($a) {
	\PHPStan\dumpType($a); // 1|2|string
};
$c(1);
$c(2);
doFoo($c); // doFoo(callable(string): void $cb)

Bleeding edge only (featureToggles.closureSignaturesFromUsages). It needs the two-pass body walk of unresolvedTemplateArguments, which bleeding edge enables too.

How it works

It builds on the two-pass driver from #6332 (StatementsHandler::processBodyStmtNodesTwoPass).

  • Markers. During the observation pass, the closure's ClosureType carries one UnresolvedTemplateArgumentType marker per non-by-ref parameter. The marker's site is the closure node and its delegate is the declared type. The value is then followed by its type through aliases, array offsets, foreach over lists of closures, unions and nested use ($c). The body itself is walked with the declared types, exactly as without the feature.
  • Sends.
    • An invocation $c(1) puts its arguments as lower bounds on the markers.
    • Callable send targets put their parameter types as lower bounds. Targets are call arguments (against the acceptor resolved from all arguments, so usort($ints, $cmp) and array_map($c, [1, 2]) work), declared returns, typed properties, @var and offsetSet.
    • Parameters of pure callees typed mixed don't count, so dumpType($c) and assertType(…, $c) are not escapes.
  • Resolution.
    • A parameter resolves to the union of its lower bounds, intersected with the declared type. Typed parameters narrow as well: array $a sent to callable(non-empty-array<int>) becomes non-empty-array<int>.
    • An optional parameter's default joins the union once the closure is invoked or sent.
    • An argument the declared type rejects keeps the declared type, so the error still reads "expects string|null, int given".
    • The second pass walks the closure with the result, and its ClosureType shows the inferred signature.
    • A variadic parameter keeps its declared type: it collects arguments of different positions, and spread again into positional parameters, their union fits none of them.
  • Invocations. The inferred signature joins all invocations. A closure invoked with narrower arguments returns what its body returns for them, so a helper called with Country::class and Region::class returns list<Country> and list<Region>, not list<Country|Region> at both calls. The body is typed like a closure passed to a callable taking those arguments, once per distinct signature. The variable is matched to the closure assigned to it in the body, or in an enclosing body for a captured variable, when the variable still holds the type the closure was created with.
  • Generators. A yield sends its key and value to the key and value types of the enclosing function's declared return type (Generator<TKey, TValue>, iterable<K, V>). yield from sends the keys and values of the iterable it delegates to. This works for unresolved template arguments too: yield new Collection([1]) in a Generator<int, Collection<int>> function gives Collection<int>. It is a separate commit.
  • Escapes. These keep the declared type:
    • a target that doesn't describe the parameters (mixed, bare callable, Closure, an untyped property or return);
    • a yield in a function whose return type describes no iterable;
    • an offset of a property or of a superglobal.
  • Bodies that don't infer at all. Bodies whose variables code outside can reach: global, $GLOBALS, variable variables, include/eval, extract/compact/get_defined_vars, or writes to by-reference parameters or uses. The scan runs lazily, only when a closure in the body asks.
  • Return types. The return type of the callables a closure is sent to becomes the expected type of the expressions it returns. A returned closure, arrow function or array literal is typed by it. This works for stored closures and for closures passed directly; for the latter the parameter's callable return type was ignored so far.

Engine fixes needed on the way:

  • A closure's sites are registered before its body is walked. Otherwise recursion through use (&$self) goes unobserved.
  • ClosureType::equals() is aware of markers. It compares describe(), and markers describe as their delegates.
  • Two different closure markers answer maybe to isSuperTypeOf() / accepts(), so Closure(m1)|Closure(m2) doesn't collapse into one closure.
  • collectCall() ignores closure markers. Without that, a callee's own templates got sites and resolved to their bounds.

Turbo. The native mirror of every touched shadowed class is updated, and the new ClosureSignatureInference service is shadowed natively.

Verification

  • make tests is green with the extension off and loaded (22,383 tests). --group levels is green, and make phpstan is clean.
  • walk-trace.php shows the PHP and native walks identical. Side-by-side, smoke, signature parity and clang-tidy on the changed sources all pass.
  • New tests, fail-first verified on the base commit:
    • nsrt/closure-signature-from-usages.php: 32 assertions failed on the base;
    • a CallToFunctionParametersRuleTest case: errors reported inside the closure body because of the inferred parameter;
    • ClosureSignatureFromUsagesToggleOffTest: legacy behaviour with the toggle off;
    • nsrt/template-argument-yield-send.php and the generator cases of the closure file: 8 assertions failed before the yield commit.
  • Self-analysis on src/ (level 8, bleeding edge) reports the identical error set with the toggle on and off.

Changed expectations, each an improvement:

  • closure-return-type.php, Reflection/data/mixedType.php and unionTypes.php: a closure only ever called with a literal now narrows to it.
  • Levels acceptTypes-5 and -7 show the inferred Closure(FooInterface, int, mixed).
  • acceptTypes-10 loses two implicit-mixed errors, because that closure is now inferred as FooInterface.

Performance

Measured on self-analysis: toggle on vs off on the same build, 8 interleaved ABBA pairs, user CPU.

Extension loaded PHP only
First version +2.2–2.5 % (t 7–12) +4.19 % (t 6.9, 4 pairs)
With settled sites (commit Replay closures whose inferred signature matches the declared one) +1.17 % (t 2.4) +1.35 % (t 10.2)

Most closure sites resolve every parameter to exactly its declared type: on src/, 139 of 156 sites. Such a site is settled:

  • The first pass already walked it, and everything it reaches, exactly as the second pass would.
  • The resolver leaves it out of the statements to re-walk.
  • The second pass builds the closure with the same markers, so its type equals the recorded one, and the statements using it replay as well.
  • The markers behave as their declared types in every type operation, and closure markers now also describe as their declared type.

On src/, re-walked statements drop from 727 to 394 (304 with the inference off).

Also on phpstan-src (bin/phpstan -vvv): dead-code-detector's "usages over unknown type" go from 28 on 2.3.x to 23 on this branch.

Follow-ups (not in this PR)

  • use (&$z): apply the closure's by-reference effects at each observed invocation, and at @param-immediately-invoked-callable arguments, instead of widening $z where the closure is created. Fall back to the current behaviour when the closure escapes.

Closes phpstan/phpstan#11317
Closes phpstan/phpstan#3770

🤖 Generated with Claude Code

https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv

ondrejmirtes and others added 7 commits September 27, 2026 03:25
A closure or arrow function written where nothing types it - assigned to a
variable, put in an array - gets its parameter types from the rest of the
enclosing body, the way a closure passed straight to a callable parameter
gets them from that parameter:

    $c = function ($a) { /* $a is 1|2|string */ };
    $c(1);
    $c(2);
    doFoo($c); // callable(string): void

It rides the two-pass body walk of unresolved template arguments: during
the observation pass the closure's ClosureType carries an
UnresolvedTemplateArgumentType marker per parameter (site: the closure node,
delegate: the declared type), so the value is followed through variables,
array offsets, unions and nested closures by its type. Invocations put their
arguments as lower bounds on the markers, callable send targets (arguments,
returns, typed properties, @var, offsetSet) put their parameter types as
lower bounds. A parameter resolves to the union of its lower bounds narrowed
to the declared type (the default value joins when there is an invocation);
a send to a target that does not describe the parameters (mixed, callable,
Closure, an untyped property or return, a yield, a property or superglobal
offset) keeps the declared type. Bodies whose variables code outside can
reach (global, $GLOBALS, variable variables, include/eval,
extract/compact/get_defined_vars, writes to by-reference parameters or uses)
do not infer at all. The second pass walks the closure body with the
resolved types and the closure's ClosureType shows them.

The return type of the callables a closure is sent to becomes the expected
type of the expressions it returns, so a returned closure, arrow function or
array literal is typed by it - for stored closures (from the observed sends)
as well as for closures passed directly (from the parameter's callable type,
which was ignored so far).

Bleeding edge only (featureToggles.closureSignaturesFromUsages, which needs
unresolvedTemplateArguments for the two-pass walk); off, the legacy path is
unchanged. The native mirrors of every touched shadowed class are updated,
and the new ClosureSignatureInference service is shadowed natively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
A yield hands its key and value to the consumer of the generator: the key
and value types of the enclosing function's declared return type
(Generator<TKey, TValue>, iterable<K, V>) are now send targets, like a
declared return type is for a returned value. yield from sends the keys
and values of the iterable it delegates to.

Unresolved template arguments resolve from them (`yield new
Collection([1])` in a Generator<int, Collection<int>> function gives
Collection<int>), and so do the parameters of closures whose signature is
inferred from their usages. A return type that describes no iterable keeps
yielded closures escaping, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
Closes phpstan/phpstan#11317

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
A closure site whose every parameter resolves to its declared type, and
whose return has no bound, is settled: the observation pass already walked
it and everything it reaches exactly as the second pass would. The
resolver leaves such sites out of the statements to re-walk, and the second
pass builds the closure with the same markers, so its type equals the
recorded one and the statements using it are replayed too. The markers
behave as their declared types in every type operation; a closure marker
now also describes as its declared type.

On src/ 139 of 156 closure sites settle and the statements re-walked in the
second pass drop from 727 to 394 (304 with the inference off).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
describe() printed a nameless copy of the closure through the PHPDoc
printer on every call, and equals() describes both sides. The two-pass
driver compares every variable against its recorded scope at every
statement, so a body with many closures whose inferred signatures differ
from their markers described them quadratically often. UnionType and
IntersectionType already keep the same per-level memo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
…erently

A value read out of an object with an unresolved template argument is the
argument's observation-pass type (TemplateTypeHelper unwraps the marker),
so a closure invoked with $collection->first(), mapped over its items or
passed to its map() observed the literal types the object was created with.
When the collection later resolved to Collection<int>, the closure kept
Closure(1|2) and every such use was reported.

When a template argument resolves to something else than it stood for
while observing and the body has a closure whose signature narrowed, the
statements from the first template argument site on are walked once more
with the template arguments resolved and without rules, observing only the
closure signatures. Like the second pass, only statements owning a site or
mentioning a variable the resolutions changed are walked; the others carry
their recorded facts over (TemplateArgumentConstraints::withRecordedFacts()).
Closure resolutions only narrow values, so the second pass needs no further
round.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
UnionType::isSuperTypeOf() compared an UnresolvedTemplateArgumentType
member by member, so a marker standing for int|string was only maybe a
subtype of BackedEnum|int|string. A closure whose inferred signature settled
keeps its markers in the second pass, and a generic callee inferring its
template type from that closure's parameter lost it - "Unable to resolve
the template type" and a closure it then rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
ondrejmirtes and others added 3 commits September 27, 2026 12:37
A variadic parameter collects arguments of different positions into one
list; spread again into positional parameters, the union of all of them
fits none. Variadic parameters keep their declared type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
A closure whose signature is inferred from its invocations joins the
arguments of all of them - a closure invoked with Country::class and
Region::class returned list<Country|Region> at both calls. Invoked with
arguments narrower than the joined parameters, the closure now returns
what its body returns for these arguments: the body is typed like a
closure passed to a callable taking them, once per distinct signature.

The invoked variable holds the closure it is assigned in the body or, for a
captured variable, in a body enclosing it, when its type is the type the
closure was created with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
@ondrejmirtes
ondrejmirtes force-pushed the closure-signatures-from-usages branch from 02e504d to e31e14e Compare September 27, 2026 10:41
@ondrejmirtes
ondrejmirtes marked this pull request as ready for review September 27, 2026 10:43
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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.

infer closure type from being passed into function's return type with callable signature PHPDoc block is ignored for closures/anonymous functions

2 participants