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.

@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 and others added 2 commits October 12, 2026 20:49
…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
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.

array_reduce could infer the precise type of the $initial argument within the callable and in the return type

1 participant