Skip to content

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

Open
gnutix wants to merge 1 commit into
phpstan:2.1.xfrom
gnutix:array-reduce-callback-inference
Open

Infer array_reduce() callback parameter types from the initial and array arguments#5978
gnutix wants to merge 1 commit into
phpstan:2.1.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 resulting return type is a subtype of the carry type, 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: only if f(W) ⊆ W is the widened type accepted. If even the widened type is not a fixed point (e.g. a nesting callback like fn ($carry, $v) => ['x' => $carry], whose result grows without bound), the callback is analysed once more with the parameter types it would have had without this feature — i.e. the current behaviour is kept verbatim, instead of claiming an unsound shape.

ParametersAcceptorSelector::selectFromArgs() (mirroring its array_map() branch) overrides the callback parameter of array_reduce() to callable(union(initial, callbackReturn), itemType): TReturn, keeping the declared return type so existing rule messages don't change. This is what rules and the callback body see, so $carry inside the body becomes the union of the initial type and the callback's (fixed-point) return type. MutatingScope::getNodeKey() 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>}

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 the rare non-converging callbacks, to restore the naive behaviour). Non-array_reduce paths through ClosureTypeResolver are untouched.

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.

…ray arguments

Co-Authored-By: Claude Fable 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.

1 participant