Skip to content

Preserve constant array shape when spreading a union of constant arrays in array literals#5774

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-lhvvfmk
Open

Preserve constant array shape when spreading a union of constant arrays in array literals#5774
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-lhvvfmk

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When using the spread operator in array literals with a value that is a union of constant arrays (e.g. ...($flag ? ['key' => true] : [])), PHPStan was degrading the result to a general array type like non-empty-array<'key'|'other', bool> instead of preserving the precise array shape array{other: bool, key?: true}.

Changes

  • Modified InitializerExprTypeResolver::getArrayType() in src/Reflection/InitializerExprTypeResolver.php to handle unions of multiple constant arrays when processing spread items
  • Changed the condition from count($constantArrays) === 1 to count($constantArrays) > 0
  • For the string-key path (PHP >= 8.1): collects all keys across all constant arrays in the union, determines optionality based on whether a key appears in all branches, and unions value types per key
  • For the integer-key path: merges by position across all constant arrays, marking positions not present in all branches as optional
  • Correctly updates $hasOffsetValueTypes tracking when merged spread keys overlap with previously-set keys

Root cause

In InitializerExprTypeResolver::getArrayType(), when a spread item's value was a union type like array{spread: true}|array{}, getConstantArrays() returned 2 constant arrays. The condition count($constantArrays) === 1 failed, causing the code to fall through to the general fallback that called $arrayBuilder->degradeToGeneralArray(), losing the array shape information entirely.

Analogous cases probed

  • OversizedArrayBuilder: Uses $valueType instanceof ConstantArrayType (single type only) — affects only arrays with >256 items, a rare edge case. Not fixed here.
  • FuncCallHandler arg unpacking ($callArg->unpack): Similar count($constantArrays) === 1 pattern for function call argument unpacking (used by array_push etc.). Different context with different semantics — not fixed here.
  • Other count($constantArrays) === 1 sites (NodeScopeResolver foreach, ConstantArrayType list-ness, ArrayType truncation): Inspected and confirmed to be unrelated to array literal spreading.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14708.php with 9 test functions covering:

  • The exact reproduction from the issue (test1, test2, test3)
  • Multiple optional keys from separate spreads
  • Overlapping keys between spread branches
  • Integer keys with union of different-length arrays
  • All branches having the same keys (non-optional result)
  • Three-branch union
  • Integer-only key unions
  • Empty vs non-empty union

Fixes phpstan/phpstan#14708

…ys in array literals

- In `InitializerExprTypeResolver::getArrayType()`, change the
  `count($constantArrays) === 1` check to `count($constantArrays) > 0`
  to handle unions of constant arrays (e.g. `array{key: T}|array{}`)
- For string-key arrays: merge keys across all constant arrays, marking
  keys not present in all branches as optional, with value types unioned
- For integer-key arrays: merge by position across all constant arrays,
  with positions not present in all branches marked optional
- Update `$hasOffsetValueTypes` tracking to correctly handle merged keys
  that overlap with previously-set keys
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.

Types with conditional array shape keys not detected properly with spread operators in array

1 participant