From 2bc277b51948c672ebab61e17da54668b74f09d9 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:13:14 +0000 Subject: [PATCH 1/2] Infer precise carry type for array_reduce callback from initial argument - Added ArrayReduceCallbackClosureTypeExtension implementing FunctionParameterClosureTypeExtension - The extension provides the initial argument type (generalized to remove literals) as the carry parameter type - The array's value type is provided as the value parameter type - New regression test in tests/PHPStan/Analyser/nsrt/bug-7280.php Closes https://github.com/phpstan/phpstan/issues/7280 --- ...rrayReduceCallbackClosureTypeExtension.php | 55 +++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-7280.php | 59 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7280.php diff --git a/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php b/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php new file mode 100644 index 00000000000..a31276c0b5c --- /dev/null +++ b/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php @@ -0,0 +1,55 @@ +getName() === 'array_reduce' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $valueType = $arrayType->getIterableValueType(); + + if (isset($args[2])) { + $initialType = $scope->getType($args[2]->value); + } else { + $initialType = new NullType(); + } + + $carryType = $initialType->generalize(GeneralizePrecision::templateArgument()); + + return new ClosureType( + [ + new NativeParameterReflection('carry', false, $carryType, $parameter->passedByReference(), false, null), + new NativeParameterReflection('value', false, $valueType, $parameter->passedByReference(), false, null), + ], + new MixedType(), + ); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7280.php b/tests/PHPStan/Analyser/nsrt/bug-7280.php new file mode 100644 index 00000000000..e408cc40a50 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7280.php @@ -0,0 +1,59 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug7280; + +use function PHPStan\Testing\assertType; + +// Test 1: Precise carry type from initial argument with constant array shape +$result1 = array_reduce( + ['test1', 'test2'], + static function (array $carry, string $value): array { + assertType("array{starts: array{}, ends: array{}}", $carry); + $carry['starts'][] = $value; + $carry['ends'][] = $value; + + return $carry; + }, + initial: ['starts' => [], 'ends' => []], +); + +// Test 2: Arrow function with precise carry type +$result2 = array_reduce( + [1, 2, 3], + static fn (int $carry, int $value): int => $carry + $value, + 0, +); +assertType('int', $result2); + +// Test 3: Carry type with no initial (defaults to null) +$result3 = array_reduce( + [1, 2, 3], + static function (?int $carry, int $value): int { + assertType('null', $carry); + return ($carry ?? 0) + $value; + }, +); + +// Test 4: Initial value type narrows carry - literal string initial +$result4 = array_reduce( + ['a', 'b', 'c'], + static function (string $carry, string $value): string { + assertType('string', $carry); + return $carry . $value; + }, + '', +); +assertType('string', $result4); + +// Test 5: Carry type with literal int initial +$result5 = array_reduce( + [1, 2, 3], + static function (int $carry, int $value): int { + assertType('int', $carry); + return $carry + $value; + }, + 0, +); +assertType('int', $result5); From 7f711a0136e660bb6e7d4990214d3fe1cadd075f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 19:24:48 +0000 Subject: [PATCH 2/2] Account for callback return type in array_reduce carry parameter The carry parameter type should be the union of the initial value type and the callback's return type, since after the first iteration $carry receives the callback's return value, not the initial value. Previously, only the initial type was used, which was incorrect for subsequent iterations (e.g., array{starts: array{}, ends: array{}} was wrong when the callback returns a modified array, and null was wrong when the callback returns int). Co-Authored-By: Claude Opus 4.6 --- .../Php/ArrayReduceCallbackClosureTypeExtension.php | 12 ++++++++++++ tests/PHPStan/Analyser/nsrt/bug-7280.php | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php b/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php index a31276c0b5c..1ed72bae39c 100644 --- a/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php +++ b/src/Type/Php/ArrayReduceCallbackClosureTypeExtension.php @@ -2,6 +2,8 @@ namespace PHPStan\Type\Php; +use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; @@ -13,7 +15,9 @@ use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\MixedType; use PHPStan\Type\NullType; +use PHPStan\Type\ParserNodeTypeToPHPStanType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ArrayReduceCallbackClosureTypeExtension implements FunctionParameterClosureTypeExtension @@ -43,6 +47,14 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $carryType = $initialType->generalize(GeneralizePrecision::templateArgument()); + $callbackExpr = $args[1]->value ?? null; + if ($callbackExpr instanceof Closure || $callbackExpr instanceof ArrowFunction) { + $callbackReturnType = ParserNodeTypeToPHPStanType::resolve($callbackExpr->returnType, null); + if (!$callbackReturnType instanceof MixedType) { + $carryType = TypeCombinator::union($carryType, $callbackReturnType); + } + } + return new ClosureType( [ new NativeParameterReflection('carry', false, $carryType, $parameter->passedByReference(), false, null), diff --git a/tests/PHPStan/Analyser/nsrt/bug-7280.php b/tests/PHPStan/Analyser/nsrt/bug-7280.php index e408cc40a50..f9181f9a2ff 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7280.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7280.php @@ -10,7 +10,7 @@ $result1 = array_reduce( ['test1', 'test2'], static function (array $carry, string $value): array { - assertType("array{starts: array{}, ends: array{}}", $carry); + assertType("array", $carry); $carry['starts'][] = $value; $carry['ends'][] = $value; @@ -31,7 +31,7 @@ static function (array $carry, string $value): array { $result3 = array_reduce( [1, 2, 3], static function (?int $carry, int $value): int { - assertType('null', $carry); + assertType('int|null', $carry); return ($carry ?? 0) + $value; }, );