Skip to content

Infer array_reduce() callback parameter types from the initial and array arguments - #5978

Open
gnutix wants to merge 2 commits into
phpstan:2.3.xfrom
gnutix:array-reduce-callback-inference
Open

gnutix wants to merge 2 commits into
phpstan:2.3.xfrom
gnutix:array-reduce-callback-inference

Conversation

@gnutix

@gnutix gnutix commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#7280

Description

Inside an array_reduce() callback, $carry was typed from the naive closure analysis (non-empty-array in the linked issue) instead of from the $initial argument, and the call's result type was correspondingly imprecise.

Root cause

array_reduce()'s callback parameter type is recursive — stubs/arrayFunctions.stub types it callable(TReturn, TIn): TReturn — and PHPStan has no fixed-point machinery to resolve it:

  • ParametersAcceptorSelector::selectFromArgs() types the closure argument naively via ClosureTypeResolver::getClosureType(), using only the declared parameter hints.
  • GenericParametersAcceptorResolver then unions that polluted return type into TReturn, which NodeScopeResolver propagates into the callback body.
  • ArrayReduceFunctionReturnTypeExtension reads the same garbage-in closure type for the call's result.

Unlike array_map(), there was no visitor connecting the callback to the other arguments of the call (ArrayMapArgVisitor is how array_map() callbacks get precise parameter types).

Design

A new ArrayReduceArgVisitor attaches the array and initial arguments (positional or named — the issue's own reproducer uses initial:) to the callback expression, skipping first-class-callable array_reduce(...) and argument unpacking.

ClosureTypeResolver::getClosureType() then resolves the callback with a bounded fixed-point iteration, for both closures and arrow functions:

  1. Pass 1 analyses the body with $carry = type of $initial and $item = the array's iterable value type. If the carry absorbs the resulting return type (union(carry, return) equals the carry), the carry type is a fixed point and we are done.
  2. Otherwise the carry type is widened: union(initial, return), generalized shape-preservingly (ConstantArrayType::generalizeValues() when all union members are constant arrays, generalize(GeneralizePrecision::lessSpecific()) otherwise), and pass 2 re-analyses the body with the widened carry W.
  3. The result is verified: the widened type is accepted only if union(W, f(W)) equals W. If even the widened type is not a fixed point (e.g. a nesting callback like fn ($carry, $v) => ['x' => $carry], or a list that gains an element per iteration), the callback is analysed once more with an unknown (mixed) carry and the array's item type, instead of claiming a shape the carry can outgrow.

The convergence check is deliberately strict. isSuperTypeOf() is not enough: without definite sealedness, an array shape is a supertype of the same shape with extra keys, so a carry growing by one key per iteration would pass as converged (e.g. $c[] = $x was inferred as a list of at most three elements). An implicit never return (inference giving up on the body) is not accepted as a fixed point either; an explicit never (a callback that always throws) is, since the carry then stays the initial value.

The fixed point is what resolves array_reduce()'s TReturn; the callback body and the rules then see the callback through that resolved acceptor, so $carry inside the body becomes the union of the initial type and the callback's (fixed-point) return type. Once the carry parameter no longer holds a template type, getClosureType() types the callback by the resolved context instead of recomputing the fixed point, so the rule view and the body walk agree (without this gate, array_reduce($ints, fn ($c, $x) => $c . $x, 'a') was reported as an invalid callback). An earlier revision of this PR, written against 2.2.x, overrode the callback parameter in ParametersAcceptorSelector; on 2.3.x the override's type getter reads the closure through a separate on-demand walk that never reaches the fixed point, so the gate replaces it. ScopeOps::nodeKey() includes the new attribute in the expression cache key for the same reason arrayMapArgs is included: cached closure types must not leak between contexts.

ArrayReduceFunctionReturnTypeExtension is unchanged; it now simply reads a precise callback type.

For the issue's reproducer this infers:

$carry  // array{starts: array{}, ends: array{}}|array{starts: non-empty-list<string>, ends: non-empty-list<string>}
$result // array{starts: non-empty-list<string>, ends: non-empty-list<string>}

Turbo

ClosureTypeResolver and ScopeOps are shadowed by phpstan_turbo, and the new ArrayReduceArgVisitor is a rich parser visitor, so the change ships with native mirrors: turbo-ext/src/ArrayReduceArgVisitor.cpp (new, with its generated header and smoke/parser-visitor coverage), the fixed point in ClosureTypeResolver.cpp, and the node-key change in pt_node_key() (support.cpp). The full test suite passes with the extension loaded and without it. The second commit bumps EXPECTED_EXTENSION_VERSION.

Alternatives

  • Generalize before giving up: when the widened carry is not a fixed point, a fourth walk could verify generalize(lessSpecific) of it before falling back to mixed — e.g. list append would become list<int> instead of mixed. Left out to keep the pass count bounded at three; easy to add if wanted.
  • Selector override instead of the context gate: reinstating a ParametersAcceptorSelector override (as in the 2.2.x revision) would need its type getter to consult the fixed point; the gate is the smaller change on 2.3.x.
  • Separate pre-existing bug found while testing: $d = []; $d[count($d)] = 1; infers *NEVER* (not addressed here; this PR just no longer treats such an implicit never as convergence).

Prior art

#5168 attempted this with a FunctionParameterClosureTypeExtension and was closed unmerged after review: typing $carry as the initial type is only correct for the first iteration, and unioning with the declared return type destroyed precision. This PR answers that soundness objection differently: the carry type is initial ∪ callbackReturn (exactly the union the reviewers asked for), the callback return comes from a verified fixed point rather than from declared hints, and when no fixed point is reached the implementation falls back to today's behaviour entirely. The extension-only route also could not fix the result type, because the return type extension reads the naive closure type, which never consults parameter-closure-type extensions.

Performance

array_reduce() callback bodies are now analysed up to two times inside getClosureType() (a third pass happens only for non-converging callbacks). Non-array_reduce paths through ClosureTypeResolver are untouched. On a synthetic file of 150 reduce-heavy functions analysis took ~1.7x as long as before (3.25s → 5.4–5.9s); nested reduces stay bounded by the existing re-walk depth guard.

Integration test impact

  • shopware/shopware: one new return.type error in WebhookManagerSubscriber::partitionArray(), a true positive — the result is keyed by the callback's partition number, so it is not the list<list<T>> the PHPDoc claims; before this PR the carry was mixed, which hid it.
  • neos/neos-development-collection: the existing argument.type error in WorkspaceController.php (a User-typed closure reducing an array<object>) stays; its message now shows array<string, string> carry types instead of array.

Companion baseline update: phpstan/phpstan#15471 (merge together with this PR).

Ecosystem note

Every array_reduce() user's carry/result types become more precise, so downstream projects may see new findings (e.g. the result of a summing reduce is now int instead of a benevolent (array|float|int)).

Existing expectations survive unchanged: the six array_reduce return types in tests/PHPStan/Analyser/nsrt/array-functions.php, the six CallToFunctionParametersRule messages (identical carry/item types), and tests/PHPStan/Rules/Operators/data/bug-7280-comment.php.

🤖 Generated with Claude Code, model Fable 5.

Closes phpstan/phpstan#15388

@gnutix
gnutix changed the base branch from 2.1.x to 2.2.x July 4, 2026 20:00
@gnutix
gnutix force-pushed the array-reduce-callback-inference branch 4 times, most recently from 6f3b3b7 to 05830c5 Compare July 5, 2026 11:40
@gnutix
gnutix force-pushed the array-reduce-callback-inference branch from 05830c5 to ef1f6b5 Compare October 12, 2026 16:38
@gnutix
gnutix changed the base branch from 2.2.x to 2.3.x October 12, 2026 16:39
@gnutix
gnutix force-pushed the array-reduce-callback-inference branch from ef1f6b5 to 87a2f4e Compare October 12, 2026 18:51
…ray arguments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
@ondrejmirtes
ondrejmirtes force-pushed the array-reduce-callback-inference branch from 87a2f4e to 35b9691 Compare October 31, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants