Skip to content

Prevent "Unresolvable type" errors when mapping constant arrays#5936

Merged
staabm merged 8 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-rxsk3do
Jun 26, 2026
Merged

Prevent "Unresolvable type" errors when mapping constant arrays#5936
staabm merged 8 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-rxsk3do

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

With bleeding edge enabled, calling a generic function whose return type is inferred
from an arrow function returning array_map(fn (Someenum $t) => $t->value, Someenum::cases())
produced false-positive errors:

Return type of call to function doFoo contains unresolvable type.
Parameter #1 $callable of function doFoo contains unresolvable type.

even though the inferred type (array{'foo'}) was correct. This PR stops array_map
(and other mapValueType callers) from planting an ErrorType inside the sealed
array's internal unsealed descriptor.

Changes

  • src/Type/Constant/ConstantArrayType.php: mapValueType() no longer applies the
    callback to the unsealed value type when the array is sealed (isUnsealed()->no()),
    i.e. when that slot is the explicit-never sentinel. It is left untouched for sealed
    arrays and still mapped for genuinely/maybe-unsealed arrays.
  • Added a rule regression test tests/PHPStan/Rules/Functions/Bug14844Test.php (+ data
    data/bug-14844.php) running CallToFunctionParametersRule under bleeding edge and
    asserting no errors.
  • Added a type-inference regression test tests/PHPStan/Analyser/nsrt/bug-14844.php
    pinning the inferred type to array{'foo'}.

Root cause

In bleeding edge a sealed ConstantArrayType carries an unsealed descriptor of
[never, never] (explicit never) representing "this array has no extra elements". The
value-type slot of that sentinel is not a real element type.

ConstantArrayType::mapValueType() mapped the unsealed value type through the supplied
callback. For array_map the callback resolves the user callback against the array's
value type; given the never sentinel it evaluates never->value (an enum-case
property fetch on never), which produces an ErrorType. That ErrorType was stored
inside the resulting array's unsealed slot — invisible in describe() but visited by
UnresolvableTypeHelper, which walks the entire type tree and flags any ErrorType,
hence the bogus "contains unresolvable type" reports.

The pattern is "transforming the sealed sentinel as if it were a real element type". The
single affected location is mapValueType, which is the chokepoint for every value-map
operation (array_map via ArrayMapFunctionReturnTypeExtension, the str_replace family
via ReplaceFunctionsDynamicReturnTypeExtension, etc.).

Test

  • Bug14844Test::testBug14844 reproduces the reported false positives under bleeding
    edge and asserts the call analyses cleanly. It fails before the fix with the two
    unresolvableType errors and passes after.
  • nsrt/bug-14844.php asserts the inferred type is array{'foo'}.
  • Probed analogous cases (all confirmed routed through and fixed by the same
    mapValueType guard, no separate bug): str_replace over a mapped enum-case array,
    nested array_map, passing the sealed array through a generic identity function,
    array spread [...$arr], and + array union. The sibling transforms
    traverse / traverseSimultaneously only propagate existing types and never plant
    the ErrorType, so they need no change.

Fixes phpstan/phpstan#14844

staabm and others added 3 commits June 26, 2026 05:20
…:mapValueType

- In bleeding edge, a sealed constant array carries an `unsealed` descriptor of
  `[never, never]` (explicit never) meaning "no extra elements". `mapValueType`
  applied the callback to that sentinel value type, which for `array_map` (and the
  str_replace family, both routed through `mapValueType`) resolves the callback
  against a `never` argument and yields an `ErrorType`.
- The `ErrorType` was hidden inside the sealed array's unsealed slot, so
  `UnresolvableTypeHelper` (which traverses the whole type) reported false-positive
  `function.unresolvableReturnType` / `argument.unresolvableType` errors even though
  the visible inferred type was correct.
- Guard `mapValueType` to leave the unsealed sentinel untouched when the array is
  sealed (`isUnsealed()->no()`), only mapping the unsealed value type for genuinely
  (or maybe) unsealed arrays.
- Probed the sibling transforms `traverse` / `traverseSimultaneously`, nested
  `array_map`, `str_replace`, generic identity pass-through, array spread and `+`
  union: all leaks originate from the single `mapValueType` chokepoint and are fixed
  at the source; the sibling traversals only propagate types and never plant the
  `ErrorType`.
@staabm staabm requested a review from VincentLanglet June 26, 2026 05:32

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we have similar bugs in other areas where we check explictly for $this->unsealed === null but do not check $this->isUnsealed()->no()?

if so, come up with test-cases and fixes

Audit of every ConstantArrayType method that reads or transforms the
`[never, never]` unsealed sentinel under bleeding edge: only mapValueType
applies a value-projecting callback (array_map's user closure body) that can
turn the never sentinel into an ErrorType. The sibling transforms
(traverse, traverseSimultaneously, generalizeValues, filterArrayRemovingFalsey,
changeKeyCaseArray) only map never to never, and array-rebuilding operations
(array_reverse, array_values, array_merge, spread) drop and recreate the
sentinel, so none of them plant an ErrorType.

These regression tests lock in that behavior across the whole operation family
(array_map nesting, str_replace, generic pass-through, +, spread, array_filter,
array_reverse, array_values, array_merge, array_change_key_case). They fail
without the mapValueType guard and pass with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's my response to the reviewer's question.

Addressing the review

do we have similar bugs in other areas where we check explicitly for $this->unsealed === null but do not check $this->isUnsealed()->no()?

I audited every site in ConstantArrayType that reads or transforms the [never, never] unsealed sentinel, and verified the answer empirically (by reverting the mapValueType guard and exercising each operation under bleeding edge).

Conclusion: mapValueType is the unique reproducible site. No other source fix is warranted. Here's why:

  • Read sites (getArray, getOffsetValueType, inferTemplateTypes, getKeysArray/getValuesArray, degradeToGeneralArray, etc.) are already gated by isUnsealed()->yes() && $this->unsealed !== null, which excludes the sentinel by construction. getIterableValueType() likewise never includes the sentinel.

  • Transform sites — the structural twins traverse, traverseSimultaneously, generalizeValues, filterArrayRemovingFalsey, changeKeyCaseArraydo touch the sentinel via $this->unsealed !== null, but they only ever map never → never (structural type traversal, generalize, falsey-removal guarded by a NeverType check). The sentinel is preserved, so no ErrorType is planted.

  • Only mapValueType applies a value-projecting callback — array_map's user-closure body, which evaluates never->value (an enum-case fetch on never) and yields ErrorType. That's the one that needed the isUnsealed()->no() guard.

The empirical test was decisive: with the fix reverted, array-rebuilding ops (array_reverse, array_values, array_merge, spread) drop and recreate the sentinel (clean), array-propagating ops (identity, +, array_filter) merely carry through whatever's already there, and only array_map plants the ErrorType.

What I added

Since the PR description said several analogous cases were manually probed but only array_map got an automated test, I converted that probe matrix into permanent regression coverage:

  • tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php + Bug14844Test::testBug14844Siblings — runs the whole operation family (nested array_map, str_replace, generic pass-through, +, spread, array_filter, array_reverse, array_values, array_merge, array_change_key_case, plain sealed literals) under bleeding edge and asserts no unresolvableType errors.

I confirmed these tests fail without the mapValueType guard (4 cases surface the bogus error) and pass with it. make phpstan and the affected suites are green.

Comment thread src/Type/Constant/ConstantArrayType.php Outdated

$newUnsealed = $this->unsealed === null
? null
$newUnsealed = $this->unsealed === null || $this->isUnsealed()->no()

@staabm staabm Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

would it make sense to prevent writing a type into $this->unsealed which is considered->isUnsealed()->no() instead ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I've addressed the reviewer's suggestion. Here's a summary:

What the reviewer asked

would it make sense to prevent writing a type into $this->unsealed which is considered ->isUnsealed()->no() instead?

Rather than guarding the single mapValueType call site, the reviewer suggested preventing the bad write at the source.

What I changed

src/Type/Constant/ConstantArrayType.php:

  1. Constructor normalization (the new fix): Whenever a ConstantArrayType is constructed with a sealed unsealed descriptor — i.e. the unsealed key is the explicit never sentinel, exactly the isUnsealed()->no() condition — the constructor now forces the unsealed value slot back to new NeverType(true). Since the value slot of a sealed array represents "no extra elements," it is never a real element type, so any projected/transformed type a caller tries to plant there (e.g. the ErrorType from mapping never->value) is discarded.

  2. Reverted the mapValueType per-call-site guard back to its simpler original form. recreate() routes every transform through new self(...), so the constructor normalization now protects all transform sites (mapValueType, traverse, generalizeValues, etc.) centrally — a strictly more robust answer to staabm's earlier "do other areas have the same bug?" question.

Verification

  • Confirmed the constructor normalization is load-bearing: with it neutralized (and the old guard already removed), the bug resurfaces with 8 bogus unresolvableType errors across the sibling cases; with it in place, all green.
  • Bug14844Test (both testBug14844 and testBug14844Siblings) — pass.
  • nsrt/bug-14844.php type inference — passes (array{'foo'}).
  • ConstantArrayTypeTest (182), full NodeScopeResolverTest (1662), all tests/PHPStan/Type/ (2961) — green.
  • make phpstan — no errors.

Committed as d81b07fde and pushed.

Centralize protection of the sealed `[never, never]` unsealed sentinel:
whenever a ConstantArrayType is sealed (its unsealed key is the explicit
`never`), force the unsealed value slot back to the explicit-never
sentinel. This prevents any caller from planting a projected/transformed
type (e.g. an ErrorType produced by mapping the sentinel as if it were a
real element) into the slot, where UnresolvableTypeHelper would later
flag it as a bogus "contains unresolvable type" error.

This supersedes the per-call-site guard in mapValueType, which is now
reverted to its simpler form since recreate() routes every transform
through the constructor normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add one more test in Bug14844Test for https://phpstan.org/r/caf9ff47-8bf2-4d60-9786-8ce9c1273fa7 (which proves the initial bug is not related to enums only)

Map a class-constant fetch (`$type::FOO`) through array_map's callback
instead of an enum-case property fetch. It routes through the same
mapValueType chokepoint and must not plant an ErrorType in the sealed
`[never, never]` unsealed sentinel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed staabm's review by adding the requested test for the playground.

What I did

The playground is a class-constant variant of the same sealed-sentinel bug: instead of mapping an enum-case property fetch ($type->value), it maps a class-constant fetch ($type::FOO) through array_map over [new Someclass()]. It produces the identical false positives:

Return type of call to function doFoo contains unresolvable type.
Parameter #1 $callable of function doFoo contains unresolvable type.

Changes

  • tests/PHPStan/Rules/Functions/data/bug-14844-class-const.php — new data file with the class-constant variant from the playground.
  • tests/PHPStan/Rules/Functions/Bug14844Test.php — added testBug14844ClassConst() running CallToFunctionParametersRule under bleeding edge and asserting no errors.

Verification

  • Test catches the bug: with the constructor normalization temporarily disabled, the test fails with exactly the two unresolvableType errors (lines 34/35). With the fix in place it passes — confirming it routes through the same mapValueType chokepoint.
  • All 3 tests in Bug14844Test pass; make phpstan is clean.
  • Committed as 9a32cc9e5 and pushed.

@staabm staabm changed the title Do not map the sealed-never sentinel value type in ConstantArrayType::mapValueType Prevent "Unresolvable type" errors when mapping constant arrays Jun 26, 2026
@staabm staabm merged commit d6abe7f into phpstan:2.2.x Jun 26, 2026
669 of 671 checks passed
@staabm staabm deleted the create-pull-request/patch-rxsk3do branch June 26, 2026 08:04
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.

Unresolvable type with arrow function and array

3 participants