From 6f6903b0eaf77f8a8e44b162bc7c9f7d1df05ea7 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Tue, 19 May 2026 21:50:58 +0000 Subject: [PATCH 1/7] Resolve per-element callback return types in array_map for closure and arrow function callbacks When array_map receives a constant array and a closure/arrow function callback, re-evaluate the closure with each element's specific type rather than using the pre-computed union return type. This is done by cloning the callback node with per-element arrayMapArgs and clearing cached types so the closure body is re-analysed with the narrowed parameter type. This restores per-element precision for cases like: array_map(fn(Role $r) => $r->value, Role::cases()) => array{'OWNER', 'ADMIN', 'EDITOR'} instead of the incorrect: => array{'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER', ...} Named function callbacks and first-class callables already had correct per-element precision through dynamic return type extensions. Closes https://github.com/phpstan/phpstan/issues/14649 --- .../ArrayMapFunctionReturnTypeExtension.php | 35 +++++++- tests/PHPStan/Analyser/nsrt/array-map.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-10685.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14649.php | 80 +++++++++++++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14649.php diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index f217fd0c96f..d66a1d8e736 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -7,12 +7,16 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Parser\ArrayMapArgVisitor; use PHPStan\Reflection\FunctionReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; @@ -128,9 +132,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if (count($constantArrays) > 0) { $totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]); if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - $mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => $scope->getType(new FuncCall($callback, [ - new Node\Arg(new TypeExpr($type)), - ]))); + $mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => self::resolveCallbackReturnType($scope, $callback, $type)); } else { $mappedArrayType = TypeCombinator::intersect(new ArrayType( $arrayType->getIterableKeyType(), @@ -162,6 +164,33 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $mappedArrayType; } + private static int $cloneCounter = 0; + + private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type + { + if ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction) { + $clone = clone $callback; + $wrappedType = new ConstantArrayType( + [new ConstantIntegerType(0)], + [$argType], + isList: TrinaryLogic::createYes(), + ); + $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); + $clone->setAttribute('phpstanCachedTypes', []); + $clone->setAttribute(ExprPrinter::ATTRIBUTE_CACHE_KEY, null); + $clone->setAttribute('startFilePos', -(++self::$cloneCounter)); + + $closureType = $scope->getType($clone); + if ($closureType->isCallable()->yes()) { + return $closureType->getCallableParametersAcceptors($scope)[0]->getReturnType(); + } + } + + return $scope->getType(new FuncCall($callback, [ + new Node\Arg(new TypeExpr($argType)), + ])); + } + /** * @return list */ diff --git a/tests/PHPStan/Analyser/nsrt/array-map.php b/tests/PHPStan/Analyser/nsrt/array-map.php index 5dbafb13907..b085e88ed4f 100644 --- a/tests/PHPStan/Analyser/nsrt/array-map.php +++ b/tests/PHPStan/Analyser/nsrt/array-map.php @@ -95,8 +95,8 @@ public function doFoo(): void assertType("array{'0', '1'}", array_map('strval', $a)); assertType("array{'0', '1'}", array_map(strval(...), $a)); - assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => strval($v), $a)); - assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a)); + assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a)); + assertType("array{'0', '1'}", array_map(fn ($v) => (string)$v, $a)); } public function doFizzBuzz(): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-10685.php b/tests/PHPStan/Analyser/nsrt/bug-10685.php index 17f51f2b266..0612a00bc6a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10685.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10685.php @@ -19,7 +19,7 @@ function identity(mixed $value): mixed public function doFoo(): void { - assertType('array{1|2|3, 1|2|3, 1|2|3}', array_map(fn($i) => $i, [1, 2, 3])); + assertType('array{1, 2, 3}', array_map(fn($i) => $i, [1, 2, 3])); assertType('array{1, 2, 3}', array_map($this->identity(...), [1, 2, 3])); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14649.php b/tests/PHPStan/Analyser/nsrt/bug-14649.php new file mode 100644 index 00000000000..f1e31730a83 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14649.php @@ -0,0 +1,80 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14649; + +use function PHPStan\Testing\assertType; + +enum Role: string +{ + case OWNER = 'OWNER'; + case ADMIN = 'ADMIN'; + case EDITOR = 'EDITOR'; + + public function isGreaterThanOrEqual(Role $role): bool + { + $map = array_map( + static fn (Role $role): string => $role->value, + self::cases() + ); + + assertType("array{'OWNER', 'ADMIN', 'EDITOR'}", $map); + + $hierarchy = array_flip($map); + + assertType("array{OWNER: 0, ADMIN: 1, EDITOR: 2}", $hierarchy); + + return $hierarchy[$this->value] <= $hierarchy[$role->value]; + } +} + +function testArrowFunctionArithmetic(): void +{ + $arr = [1, 2, 3]; + $result = array_map(fn(int $x): int => $x * 2, $arr); + assertType("array{2, 4, 6}", $result); +} + +function testClosureArithmetic(): void +{ + $arr = [1, 2, 3]; + $result = array_map(function (int $x): int { return $x * 2; }, $arr); + assertType("array{2, 4, 6}", $result); +} + +function testArrowFunctionStringConcat(): void +{ + $arr = ['a', 'b', 'c']; + $result = array_map(fn(string $s): string => $s . '_suffix', $arr); + assertType("array{'a_suffix', 'b_suffix', 'c_suffix'}", $result); +} + +function testNamedFunctionCallback(): void +{ + $arr = ['FOO', 'BAR', 'BAZ']; + $result = array_map('strtolower', $arr); + assertType("array{'foo', 'bar', 'baz'}", $result); +} + +enum IntEnum: int +{ + case A = 10; + case B = 20; +} + +function testIntBackedEnum(): void +{ + $result = array_map( + static fn (IntEnum $e): int => $e->value, + IntEnum::cases() + ); + assertType("array{10, 20}", $result); +} + +function testClosureWithStringKeys(): void +{ + $arr = ['x' => 1, 'y' => 2]; + $result = array_map(fn(int $v): string => (string)$v, $arr); + assertType("array{x: '1', y: '2'}", $result); +} From 59e6afa7b25fe71a9e0228967f973a12ee65c627 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 22:05:05 +0000 Subject: [PATCH 2/7] Use static variable instead of static class property for clone counter Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/ArrayMapFunctionReturnTypeExtension.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index d66a1d8e736..c935732ae05 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -164,11 +164,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $mappedArrayType; } - private static int $cloneCounter = 0; - private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type { if ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction) { + static $cloneCounter = 0; + $clone = clone $callback; $wrappedType = new ConstantArrayType( [new ConstantIntegerType(0)], @@ -178,7 +178,7 @@ private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callb $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); $clone->setAttribute('phpstanCachedTypes', []); $clone->setAttribute(ExprPrinter::ATTRIBUTE_CACHE_KEY, null); - $clone->setAttribute('startFilePos', -(++self::$cloneCounter)); + $clone->setAttribute('startFilePos', -(++$cloneCounter)); $closureType = $scope->getType($clone); if ($closureType->isCallable()->yes()) { From 10d1b7b6e08beffd512b01d369f9c5117582cb17 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 22:09:29 +0000 Subject: [PATCH 3/7] Add regression test for array_map preserving per-element types in tuples Closes https://github.com/phpstan/phpstan/issues/11656 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11656.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11656.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11656.php b/tests/PHPStan/Analyser/nsrt/bug-11656.php new file mode 100644 index 00000000000..15a89c9f426 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11656.php @@ -0,0 +1,22 @@ + $value, $data); + + assertType("array{array, string}", $data); + + return $data[1]; + } +} From 55ff90b6c629c00b3309b3008dd36c3aa3cf9e62 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 22:23:23 +0000 Subject: [PATCH 4/7] Remove mixed typehint from bug-11656 test for PHP 7.4 compatibility Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11656.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11656.php b/tests/PHPStan/Analyser/nsrt/bug-11656.php index 15a89c9f426..cc3a6af8514 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11656.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11656.php @@ -11,7 +11,7 @@ class Test /** * @param array{string[], string} $data */ - public function test(mixed $data): string + public function test($data): string { $data = array_map(static fn ($value) => $value, $data); From 80b8787a0a33716170fc8e45c6ecf74fca43fad3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 05:38:51 +0000 Subject: [PATCH 5/7] Use instance property instead of static variable for clone counter Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/ArrayMapFunctionReturnTypeExtension.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index c935732ae05..b8e629d140d 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -35,6 +35,8 @@ final class ArrayMapFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + private int $cloneCounter = 0; + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'array_map'; @@ -132,7 +134,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if (count($constantArrays) > 0) { $totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]); if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - $mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => self::resolveCallbackReturnType($scope, $callback, $type)); + $mappedArrayType = $arrayType->mapValueType(fn (Type $type): Type => $this->resolveCallbackReturnType($scope, $callback, $type)); } else { $mappedArrayType = TypeCombinator::intersect(new ArrayType( $arrayType->getIterableKeyType(), @@ -164,11 +166,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $mappedArrayType; } - private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type + private function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type { if ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction) { - static $cloneCounter = 0; - $clone = clone $callback; $wrappedType = new ConstantArrayType( [new ConstantIntegerType(0)], @@ -178,7 +178,7 @@ private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callb $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); $clone->setAttribute('phpstanCachedTypes', []); $clone->setAttribute(ExprPrinter::ATTRIBUTE_CACHE_KEY, null); - $clone->setAttribute('startFilePos', -(++$cloneCounter)); + $clone->setAttribute('startFilePos', -(++$this->cloneCounter)); $closureType = $scope->getType($clone); if ($closureType->isCallable()->yes()) { From 9b37858114e363281f32f35baad150fe9cbfa3eb Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 05:47:10 +0000 Subject: [PATCH 6/7] Include arrayMapArgs content in node cache key instead of using fake startFilePos Instead of assigning fake negative startFilePos values to closure clones to generate unique cache keys, include the arrayMapArgs attribute content in getNodeKey(). Each clone's TypeExpr arg prints as a unique string via the existing printer, so the cache key is naturally unique per element type without needing a static counter or position manipulation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 6 +++++- src/Type/Php/ArrayMapFunctionReturnTypeExtension.php | 9 ++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 214844aac16..b835b5e40fe 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -926,7 +926,11 @@ public function getNodeKey(Expr $node): string && (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null) && (($attributes['startFilePos'] ?? null) !== null) ) { - $key .= '/*' . $attributes['startFilePos'] . '*/'; + $key .= '/*' . $attributes['startFilePos']; + foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) { + $key .= ':' . $this->exprPrinter->printExpr($arg->value); + } + $key .= '*/'; } if (($attributes[self::KEEP_VOID_ATTRIBUTE_NAME] ?? null) === true) { diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index b8e629d140d..fcd0a142360 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -7,7 +7,6 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; -use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Parser\ArrayMapArgVisitor; use PHPStan\Reflection\FunctionReflection; use PHPStan\TrinaryLogic; @@ -35,8 +34,6 @@ final class ArrayMapFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - private int $cloneCounter = 0; - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'array_map'; @@ -134,7 +131,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if (count($constantArrays) > 0) { $totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]); if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - $mappedArrayType = $arrayType->mapValueType(fn (Type $type): Type => $this->resolveCallbackReturnType($scope, $callback, $type)); + $mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => self::resolveCallbackReturnType($scope, $callback, $type)); } else { $mappedArrayType = TypeCombinator::intersect(new ArrayType( $arrayType->getIterableKeyType(), @@ -166,7 +163,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $mappedArrayType; } - private function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type + private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type { if ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction) { $clone = clone $callback; @@ -177,8 +174,6 @@ private function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Ty ); $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); $clone->setAttribute('phpstanCachedTypes', []); - $clone->setAttribute(ExprPrinter::ATTRIBUTE_CACHE_KEY, null); - $clone->setAttribute('startFilePos', -(++$this->cloneCounter)); $closureType = $scope->getType($clone); if ($closureType->isCallable()->yes()) { From 3f301e7da08eeb7e872fe055410bc450ea19130b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 23 May 2026 11:18:59 +0000 Subject: [PATCH 7/7] Remove dead isCallable() guard for closure/arrow function callback types Scope::getType() on a Closure or ArrowFunction AST node always returns a ClosureType, which is always callable. The isCallable()->yes() check was therefore always true, making the else branch (FuncCall fallback) dead code for these node types. This also eliminates an unkillable mutation testing mutant (yes() vs !no()). Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/ArrayMapFunctionReturnTypeExtension.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index fcd0a142360..75766e35fd9 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -175,10 +175,7 @@ private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callb $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); $clone->setAttribute('phpstanCachedTypes', []); - $closureType = $scope->getType($clone); - if ($closureType->isCallable()->yes()) { - return $closureType->getCallableParametersAcceptors($scope)[0]->getReturnType(); - } + return $scope->getType($clone)->getCallableParametersAcceptors($scope)[0]->getReturnType(); } return $scope->getType(new FuncCall($callback, [