Skip to content

Resolve per-element callback return types in array_map for closure and arrow function callbacks#5716

Open
phpstan-bot wants to merge 6 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ug7q7ni
Open

Resolve per-element callback return types in array_map for closure and arrow function callbacks#5716
phpstan-bot wants to merge 6 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ug7q7ni

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 19, 2026

Summary

  • When array_map maps a closure/arrow function over a constant array, clone the callback node per element with the specific element type as arrayMapArgs, clearing cached types and assigning unique node positions so the closure body is re-analysed with narrowed parameter types
  • This restores per-element precision (e.g. array{'OWNER', 'ADMIN', 'EDITOR'} instead of array{'ADMIN'|'EDITOR'|'OWNER', ...}) which also fixes cascading array_flip() false positives
  • Named function callbacks and first-class callables already had correct per-element precision and are unaffected

Test plan

  • Added regression test tests/PHPStan/Analyser/nsrt/bug-14649.php covering enum value access, arithmetic, string concat, named function callback, int-backed enum, and string keys
  • Updated two existing test files (bug-10685.php, array-map.php) that were asserting the buggy union-type behavior
  • make tests passes (12,122 tests, 79,747 assertions)
  • make phpstan passes (0 errors)
  • make cs-fix passes (no violations)

Closes phpstan/phpstan#14649
Closes phpstan/phpstan#11656

…d 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 phpstan/phpstan#14649
Comment thread src/Type/Php/ArrayMapFunctionReturnTypeExtension.php Outdated
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add non regression test for

phpstan-bot and others added 2 commits May 19, 2026 22:05
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Closes phpstan/phpstan#11656

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed successfully. Here's what was done:

  1. Added regression test for issue #11656 (tests/PHPStan/Analyser/nsrt/bug-11656.php) — verifies that array_map with an identity closure over a tuple array{string[], string} preserves per-element types (so $data[1] is string, not array<string>|string).

  2. Static variable refactor was already applied remotely — the $cloneCounter static class property was converted to a static local variable inside resolveCallbackReturnType().

All tests pass (12,123 tests, 79,748 assertions) and make phpstan reports 0 errors.

Comment thread tests/PHPStan/Analyser/nsrt/bug-11656.php Outdated
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why named function callbacks and first-class callables already had correct per-element precision ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

No permission to comment on the upstream repo. Since the reviewer's question is purely informational (no code changes requested), and the review was already approved (APPROVED state from the previous review), there are no code changes to make or push.

Here's the answer to the reviewer's question that should be posted to the PR:

Why named function callbacks and first-class callables already had correct per-element precision:

The difference is in how MutatingScope::getType() resolves the return type when FuncCall is created with the callback as callee:

  • Named functions ('strtolower') and first-class callables (strval(...)): The FuncCallHandler extracts the function name and calls getDynamicFunctionReturnType(), which invokes the function's return-type extension with the specific per-element argument type. Each call is independent — the extension recomputes the return type fresh for each input.

  • Closures/arrow functions (fn($v) => $v->value): There is no function name to look up. getType() on the closure node returns the closure's already-cached type — computed during initial analysis when NodeScopeResolver walked the closure body with the union of all possible argument types. Since the closure node identity (key + scope cache key) doesn't change between per-element calls, the same cached ClosureType is returned, with the unionized return type.

That's why the fix clones the closure node with a unique startFilePos, clears cached types, and sets the specific element type via ArrayMapArgVisitor::ATTRIBUTE_NAME — forcing MutatingScope to treat each clone as a fresh closure and re-analyse the body with the narrowed parameter type.

No code changes are needed for this review comment — it was a question about the design, not a change request. The PR was already approved by VincentLanglet.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the fix be inside MutatingScope and/or FuncCallHandler rather than ArrayMapFunctionReturnTypeExtension ?

@VincentLanglet VincentLanglet requested a review from staabm May 19, 2026 22:53
@VincentLanglet
Copy link
Copy Markdown
Contributor

NB: @staabm In phpstan/phpstan#14649 there is also a bug about array_flip but I'll fix it in another PR.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

No code changes needed — the reviewer's comment is a design question. The fix correctly lives in ArrayMapFunctionReturnTypeExtension because:

  1. It owns array_map's per-element semantics — only this extension knows the callback should be evaluated once per element with distinct types
  2. It reuses existing infrastructure — the ArrayMapArgVisitor::ATTRIBUTE_NAME channel to ClosureTypeResolver (line 100) already exists for exactly this purpose; the fix just provides a more specific type per clone
  3. FuncCallHandler would be the wrong layer — it handles generic $expr($args) dispatch and shouldn't know about ArrayMapArgVisitor attributes or closure cloning, which are array_map-specific concerns
  4. MutatingScope would be worse — adding closure-cloning logic to getType() (called for every expression in analysis) would hurt performance and couple the core scope to array_map semantics
  5. DynamicFunctionReturnTypeExtension is the designed extension point — PHPStan's architecture specifically provides these for customizing function return type resolution

@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 20, 2026

phpstan/phpstan#14649 seem to be a regression.

Do we know when/how it broke in the recent release?

Comment thread src/Type/Php/ArrayMapFunctionReturnTypeExtension.php Outdated
Comment thread src/Type/Php/ArrayMapFunctionReturnTypeExtension.php Outdated
phpstan-bot and others added 2 commits May 20, 2026 05:38
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@VincentLanglet
Copy link
Copy Markdown
Contributor

VincentLanglet commented May 20, 2026

phpstan/phpstan#14649 seem to be a regression.

Do we know when/how it broke in the recent release?

Given the fact phpstan/phpstan#11656 is a feature request here for a long time I would say the regression might not be related to array_map but more with array_flip
array_flip(0: 'a'|'b'|'c', 1: 'a'|'b'|'c', 2: 'a'|'b'|'c'} shouldn't gives array{a?: 2, b?: 2, c?: 2}.

https://phpstan.org/r/4f185506-7a6f-45dc-8082-d717cb448b7d

I made a dedicated issue phpstan/phpstan#14656

@VincentLanglet VincentLanglet self-assigned this May 20, 2026
@VincentLanglet VincentLanglet requested a review from staabm May 20, 2026 08:56
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.

3 participants