Skip to content

Type the array_reduce() carry as a loop variable - #1

Closed
gnutix wants to merge 1 commit into
2.3.xfrom
array-reduce-carry-type
Closed

gnutix wants to merge 1 commit into
2.3.xfrom
array-reduce-carry-type

Conversation

@gnutix

@gnutix gnutix commented Nov 7, 2026 •

Copy link
Copy Markdown
Owner

array_reduce() is a loop: the carry starts as the initial value, and each pass replaces it with what the callback returns. PHPStan typed the callback's carry from the stub's callable(TReturn, TIn): TReturn, with TReturn inferred from the callback's own return type before the callback had a carry type. So the carry ended up as mixed, or a vague non-empty-array, and code reached for /** @var list<X> $carry */ inside the callback.

This change types the carry the way a foreach types a variable it writes on every pass:

  1. Start with the type of the initial value (null when there is none).
  2. Get the callback's return type with the carry typed as that, and union the two.
  3. Repeat until nothing new comes in. From the second pass on, widen the carry with the same rules a loop variable is widened with (MutatingScope::generalizeWith()), so array{} → array{x} → … becomes list<x>, and 0 → 0|1 → … becomes int<0, max>. Use the loop's own LOOP_SCOPE_ITERATIONS and GENERALIZE_AFTER_ITERATION constants.
  4. If no fixed point is reached in that budget, keep today's behaviour. A carry the callback can still outgrow would be unsound.
  5. If the callback's return type comes out as an implicit never (its body's inference gave up, as $carry[count($carry)] = … currently makes it), also keep today's behaviour: that says nothing about the carry, and taking the initial value as a fixed point would be unsound. An explicit never (a callback that always throws) still counts, so the result is the initial value.

This works only when the callback is a closure or arrow function written in the call: that is the only case where the carry is inferred rather than declared. A named function, a first-class callable or a variable keeps its declared signature, as before.

There are three pieces:

  • ArrayReduceCarryTypeResolver runs the fixed point. It asks ClosureTypeResolver::getClosureType() for the return type, given a CallableType that types the parameters. That call is cached per parameter typing, so repeated passes are cheap.
  • ArrayReduceParameterClosureTypeExtension (a new FunctionParameterClosureTypeExtension) gives the closure callable(<carry>, <item>), so its body is walked once, with the converged carry.
  • ArrayReduceFunctionReturnTypeExtension takes the callback's return type from the same resolver. It no longer relies on the closure having been walked first, which also helps a synthetic array_reduce call that a wrapper's return type extension builds. It now decides between "initial", "callback return" and "both" with isIterableAtLeastOnce() on any array type, not only on constant arrays. A non-empty-list never returns the initial value.

No turbo-shadowed class is touched. The widening goes through the scope's public assignVariable() and generalizeWith() on a throwaway variable, so it adds no new method to MutatingScope.

Before / after

Code Before After
issue's carry (initial: ['starts' => [], 'ends' => []]) non-empty-array array{starts: list<'test1'|'test2'>, ends: list<…>}
issue's result non-empty-array&hasOffsetValue('ends', mixed)&hasOffsetValue('starts', mixed) array{starts: non-empty-list<…>, ends: non-empty-list<…>}
fn ($curr, $n) => $curr + $n, 0, over array<int, int> (float|int) int
$carry[$s] = strlen($s); return $carry;, [] mixed array<string, int<0, max>>
$carry[$s[0] ?? ''][] = $s;, [] array array<string, non-empty-list<string>>
fn ($c) => $c + 1, 0, over non-empty-list (float|int) int<1, max>
nested sum over list<list<int>> (float|int) int

Two existing expectations change because they become more precise:

  • bug-15388.php: (float|int) becomes int. That issue complained that summing ints gave float|int.
  • array-functions.php: a reduce over array_unique([1, 2, 3]), which is a non-empty-array, no longer includes the initial value.

Note on the issue's own expectation: it asked for the carry to be array{starts: array<never>, ends: array<never>} inside the callback. That is only true on the first pass; on the second pass the lists are non-empty. The carry is now the union over all passes, list<…>.

Verification

  • Every new or changed assertion fails with the source change stashed and passes with it.
  • Full suite: 23,566 tests OK (4 workers, PHP 8.5). PHPStan's self-analysis is clean on the touched files and on every src/ file calling array_reduce. phpcs is clean.
  • tipee-shaped call sites that today carry a @var on the carry or the result now infer the same types by themselves. For example, the ['save' => [], 'delete' => []] buckets destructure to list<Setting>, and $carry[] = $date->format('Y') gives list<numeric-string>.

Comparison PR on the fork, not meant for upstream. Issue: phpstan/phpstan#7280.

🤖 Generated with Claude Code

The callback's carry starts as the initial value and grows by what the
closure returns for it, widened with the loop's generalization rules,
until the closure adds nothing new. The closure body is walked with
that carry, and the call returns what the closure returns for it - or
the initial value too, when the array may be empty.

A closure whose return type comes out as an implicit never (its body's
inference gave up) says nothing about the carry, so it keeps the
callback's declared typing instead of passing for a fixed point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gnutix
gnutix force-pushed the array-reduce-carry-type branch from 1e8a9ca to 0b49c76 Compare November 7, 2026 10:42
@gnutix

gnutix commented Nov 7, 2026

Copy link
Copy Markdown
Owner Author

Forwarded to PHPStan's repository: phpstan#6996

@gnutix gnutix closed this Nov 7, 2026
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