Infer signatures of closures from where their values are sent - #6604
Merged
Merged
Conversation
ondrejmirtes
force-pushed
the
closure-signatures-from-usages
branch
from
September 26, 2026 17:44
fb29224 to
0c6ae8a
Compare
ondrejmirtes
force-pushed
the
closure-signatures-from-usages
branch
2 times, most recently
from
September 26, 2026 20:23
29a4dfb to
faabf44
Compare
This was referenced Sep 27, 2026
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
force-pushed
the
closure-signatures-from-usages
branch
from
September 27, 2026 01:26
faabf44 to
80f44c0
Compare
ondrejmirtes
force-pushed
the
closure-signatures-from-usages
branch
from
September 27, 2026 10:05
80f44c0 to
02e504d
Compare
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
force-pushed
the
closure-signatures-from-usages
branch
from
September 27, 2026 10:41
02e504d to
e31e14e
Compare
ondrejmirtes
marked this pull request as ready for review
September 27, 2026 10:43
Collaborator
|
This pull request has been marked as ready for review. |
This was referenced Sep 27, 2026
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.
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:
Bleeding edge only (
featureToggles.closureSignaturesFromUsages). It needs the two-pass body walk ofunresolvedTemplateArguments, which bleeding edge enables too.How it works
It builds on the two-pass driver from #6332 (
StatementsHandler::processBodyStmtNodesTwoPass).ClosureTypecarries oneUnresolvedTemplateArgumentTypemarker 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,foreachover lists of closures, unions and nesteduse ($c). The body itself is walked with the declared types, exactly as without the feature.$c(1)puts its arguments as lower bounds on the markers.usort($ints, $cmp)andarray_map($c, [1, 2])work), declared returns, typed properties,@varandoffsetSet.mixeddon't count, sodumpType($c)andassertType(…, $c)are not escapes.array $asent tocallable(non-empty-array<int>)becomesnon-empty-array<int>.ClosureTypeshows the inferred signature.Country::classandRegion::classreturnslist<Country>andlist<Region>, notlist<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.yieldsends 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 fromsends the keys and values of the iterable it delegates to. This works for unresolved template arguments too:yield new Collection([1])in aGenerator<int, Collection<int>>function givesCollection<int>. It is a separate commit.mixed, barecallable,Closure, an untyped property or return);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.Engine fixes needed on the way:
use (&$self)goes unobserved.ClosureType::equals()is aware of markers. It comparesdescribe(), and markers describe as their delegates.isSuperTypeOf()/accepts(), soClosure(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
ClosureSignatureInferenceservice is shadowed natively.Verification
make testsis green with the extension off and loaded (22,383 tests).--group levelsis green, andmake phpstanis clean.walk-trace.phpshows the PHP and native walks identical. Side-by-side, smoke, signature parity and clang-tidy on the changed sources all pass.nsrt/closure-signature-from-usages.php: 32 assertions failed on the base;CallToFunctionParametersRuleTestcase: errors reported inside the closure body because of the inferred parameter;ClosureSignatureFromUsagesToggleOffTest: legacy behaviour with the toggle off;nsrt/template-argument-yield-send.phpand the generator cases of the closure file: 8 assertions failed before the yield commit.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.phpandunionTypes.php: a closure only ever called with a literal now narrows to it.acceptTypes-5and-7show the inferredClosure(FooInterface, int, mixed).acceptTypes-10loses two implicit-mixed errors, because that closure is now inferred asFooInterface.Performance
Measured on self-analysis: toggle on vs off on the same build, 8 interleaved ABBA pairs, user CPU.
Replay closures whose inferred signature matches the declared one)Most closure sites resolve every parameter to exactly its declared type: on
src/, 139 of 156 sites. Such a site is settled: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 on2.3.xto 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-callablearguments, instead of widening$zwhere 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