Skip to content

Resolve the templates a closure argument only receives before typing it - #6999

Open
gnutix wants to merge 2 commits into
phpstan:2.3.xfrom
gnutix:closure-param-offset-template
Open

gnutix wants to merge 2 commits into
phpstan:2.3.xfrom
gnutix:closure-param-offset-template

Conversation

@gnutix

@gnutix gnutix commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Builds on #6994: its commit is the first one here, only the last commit is new. Both change gatherClosureArgType() and share its sibling-argument template lookup, so this one waits for #6994.

A closure argument is gathered against the raw callable(T): U parameter, so while its body is typed, its parameter is the callee's own T. An offset or a property read off it is mixed, since T says nothing about what it will be, and U is inferred from that mixed. Returning the parameter itself worked only because T was resolved afterwards; reading anything off it did not.

gatherClosureArgType() now resolves the templates the callable only passes to the closure (referenced by its parameters, not by its return type) from the other arguments gathered so far, before the closure is typed. The ones in its return type stay raw, so the closure's own return still takes part in their inference (a reduce carry, or #6994's callable(T): T). When the other arguments say nothing about a template, or pass no value of it (an empty array), it stays raw too. The extra template selection only runs when the parameter has such a template, and #6994's resolution reuses its result.

before after
issue: map([['a', 'b'], ['c', 'd']], fn ($x) => [$x[0], $x[1]]) list<array{mixed, mixed}> list<array{'a'|'c', 'b'|'d'}>
map($rows, fn (array $row) => $row['name']) list<mixed> list<string>
map($objects, fn (object $o) => $o->name) list list<string>
mapWithKeys($rows, fn (array $row): iterable => yield $row['id'] => $row['name']) array<mixed> array<int, string>
reduce($rows, fn (int $carry, array $row) => $carry + $row['id'], 0) float|int int
(new Mapped($rows, fn (array $row) => $row['name']))->first() (class templates) mixed string

The issue's expected type has 'a'|'b' and 'c'|'d', but its own comment says $x[0] / $x[1] are 'a'|'c' / 'b'|'d'; the regression test asserts the latter. The test also covers methods, static methods, constructors, named arguments, the callable before the array, a nullable callable, nested calls and a caller's own template.

Two existing assertions recorded the old result:

  • nsrt/bug-11861.php:58: array<int<0, max>, array<string, int>>, what its filtering closure returns, instead of array<int<0, max>, array<int|string, mixed>>.
  • nsrt/generics.php:159: the untyped $a now gets the int its sibling argument passes, so (string) $a is array<decimal-int-string>, like the natively typed int $a on line 153.

ParametersAcceptorSelector::selectFromArgs() still types closures against the raw parameter. I found no case where that shows: the call's type and the argument rules read the gathered result (a bounded U of string with fn (array $row) => $row['name'] reports nothing, before or after).

The C++ mirror in turbo-ext/src/ArgumentsHandler.cpp gets the same change, and generated/ArgumentsHandler.h is regenerated. Verified with the extension built against PHP 8.5.12 (NTS) with the strict warning flags: smoke.php ALL OK, signature-parity.php OK, side-by-side.php OK, and identical --error-format=raw output with and without the extension on the regression files (result cache cleared between runs). The full test suite passes with and without the extension (23,570 tests, the same 2 deprecations as on #6994's commit), and make phpstan reports nothing.

Performance: an interleaved local A/B (user CPU, one core) on a file with 1,200 closures passed to generic callables gives 7.3–8.9s with this change against 9.0–11.9s on #6994's commit.

Closes phpstan/phpstan#14885

🤖 Generated with Claude Code (Opus 5.5)

gnutix and others added 2 commits November 7, 2026 11:09
… the raw parameter

A closure argument is gathered with the raw callable(T): T parameter
pushed, so a closure returning its parameter reads back the callee's own
T (string&T with a native string type). That type then took part in the
inference of T, which kept the self-reference: the closure parameter was
'a'|'b'|(string&T), and an exhaustive match on it was reported as
unhandled.

The callee's own templates in the gathered type are now resolved from the
other arguments gathered so far, falling back to the template's default
or bound when they say nothing about it or pass no value of it (an empty
array). The gather is unchanged when the closure type does not reference
them. Mirrored in the native ArgumentsHandler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A closure argument was gathered against the raw callable(T): U parameter,
so while its body was typed, its parameter was the callee's own T. An
offset or a property read off it was mixed, since T says nothing about
what it will be, and U was inferred from that mixed:
map($rows, fn (array $row) => $row['name']) returned list<mixed>.

The templates the callable only passes to the closure - referenced by its
parameters, not by its return type - are now resolved from the other
arguments gathered so far before the closure is typed. The ones in its
return type stay raw, so the closure's own return still takes part in
their inference. Mirrored in the native ArgumentsHandler.

Two assertions recorded the old result: bug-11861 now gets the
array<string, int> its filtering closure returns, and an untyped
closure parameter in generics.php now gets the int its sibling argument
passes, as the natively typed one next to it already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Closure parameter loses inferred template type (becomes mixed) when using array offset access

1 participant