Repository navigation
Conversation
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
force-pushed
the
array-reduce-carry-type
branch
from
November 7, 2026 10:42
1e8a9ca to
0b49c76
Compare
Owner
Author
|
Forwarded to PHPStan's repository: phpstan#6996 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'scallable(TReturn, TIn): TReturn, withTReturninferred from the callback's own return type before the callback had a carry type. So the carry ended up asmixed, or a vaguenon-empty-array, and code reached for/** @var list<X> $carry */inside the callback.This change types the carry the way a
foreachtypes a variable it writes on every pass:nullwhen there is none).MutatingScope::generalizeWith()), soarray{}→array{x}→ … becomeslist<x>, and0→0|1→ … becomesint<0, max>. Use the loop's ownLOOP_SCOPE_ITERATIONSandGENERALIZE_AFTER_ITERATIONconstants.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 explicitnever(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:
ArrayReduceCarryTypeResolverruns the fixed point. It asksClosureTypeResolver::getClosureType()for the return type, given aCallableTypethat types the parameters. That call is cached per parameter typing, so repeated passes are cheap.ArrayReduceParameterClosureTypeExtension(a newFunctionParameterClosureTypeExtension) gives the closurecallable(<carry>, <item>), so its body is walked once, with the converged carry.ArrayReduceFunctionReturnTypeExtensiontakes the callback's return type from the same resolver. It no longer relies on the closure having been walked first, which also helps a syntheticarray_reducecall that a wrapper's return type extension builds. It now decides between "initial", "callback return" and "both" withisIterableAtLeastOnce()on any array type, not only on constant arrays. Anon-empty-listnever returns the initial value.No turbo-shadowed class is touched. The widening goes through the scope's public
assignVariable()andgeneralizeWith()on a throwaway variable, so it adds no new method toMutatingScope.Before / after
initial: ['starts' => [], 'ends' => []])non-empty-arrayarray{starts: list<'test1'|'test2'>, ends: list<…>}non-empty-array&hasOffsetValue('ends', mixed)&hasOffsetValue('starts', mixed)array{starts: non-empty-list<…>, ends: non-empty-list<…>}fn ($curr, $n) => $curr + $n,0, overarray<int, int>(float|int)int$carry[$s] = strlen($s); return $carry;,[]mixedarray<string, int<0, max>>$carry[$s[0] ?? ''][] = $s;,[]arrayarray<string, non-empty-list<string>>fn ($c) => $c + 1,0, overnon-empty-list(float|int)int<1, max>list<list<int>>(float|int)intTwo existing expectations change because they become more precise:
bug-15388.php:(float|int)becomesint. That issue complained that summing ints gavefloat|int.array-functions.php: a reduce overarray_unique([1, 2, 3]), which is anon-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
src/file callingarray_reduce. phpcs is clean.@varon the carry or the result now infer the same types by themselves. For example, the['save' => [], 'delete' => []]buckets destructure tolist<Setting>, and$carry[] = $date->format('Y')giveslist<numeric-string>.Comparison PR on the fork, not meant for upstream. Issue:
phpstan/phpstan#7280.🤖 Generated with Claude Code