From d547e1dacd25b996592ac9807ab5a74cb6c1716d 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 552aa3099e8266018b5626e6a39fc6f1222160db 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 27e4bca70aabcc2c0c56eec4959a250fffeb90e9 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 50c3edc90f587bbcf713f63934bd30802deb61d9 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 6710d86f46cd52f233653561fe876382bcf2ca14 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 5271dbf4c6ece67c9e045f7ad2d248a260209da9 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 3b98fa813a3..67193b0fefc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -924,7 +924,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 d5a48375c0a4213d4904ee7078512ff803096fa8 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, [