Skip to content

Replace instead of union when writing to optional keys via union offset in ConstantArrayTypeBuilder#5566

Merged
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-h8hz5mm
Apr 29, 2026
Merged

Replace instead of union when writing to optional keys via union offset in ConstantArrayTypeBuilder#5566
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-h8hz5mm

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When assigning to nested array keys using a union offset type (e.g. $result[$k]['x'] = 1; $result[$k]['y'] = 2; where $k is 'a'|'b'), PHPStan incorrectly marked subsequent inner keys as optional. The second write's value was unioned with the stale value from before that write, causing y to appear as y?: 2 instead of y: 2.

Changes

  • src/Type/Constant/ConstantArrayTypeBuilder.php: In the union-key matching path of setOffsetValueType, when $optional is false and the matching key is in $this->optionalKeys, replace the value instead of unioning. For optional keys, the key's presence implies it was the target of the union-key write, so the old value is irrelevant (the key either doesn't exist or was the write target — in both cases, the old per-key value shouldn't be unioned with the new value).
  • tests/PHPStan/Analyser/nsrt/bug-11846.php: Updated assertions that were testing the buggy overapproximate behavior. The old assertions included array{}|array{array{}} as the value type, but the correct type is array{array{}} since both sequential writes always execute together.
  • tests/PHPStan/Analyser/nsrt/bug-14551.php: New regression test covering:
    • Original bug: foreach over non-empty-list<'a'|'b'> with two nested key writes
    • Three nested key writes in foreach
    • Non-foreach context with union key
    • Integer union keys (0|1)
    • Three-way union key ('a'|'b'|'c')
    • Required keys still correctly union (not replaced)
    • Non-nested union-key overwrites

Root cause

ConstantArrayTypeBuilder::setOffsetValueType handles union offset types (like 'a'|'b') by extracting the constant scalar types and, when all match existing keys, updating each key's value with TypeCombinator::union(old_value, new_value). This union is correct for required keys (the key exists regardless of which union member was the write target, so the value is either the new value or the unchanged old value). But for optional keys, the union is incorrect: an optional key's presence implies it was the write target (since it only exists because a union-key write created it), so the old value is stale and the new value should replace it outright.

The $optional parameter (which controls union-vs-replace behavior for single-key writes) was not being consulted in the union-key code path — the fix aligns the behavior.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14551.php: Seven test functions covering the reported bug and structurally analogous cases (integer keys, three-way unions, nested vs non-nested writes, required-key preservation).
  • tests/PHPStan/Analyser/nsrt/bug-11846.php: Updated existing assertions to match the now-correct (more precise) behavior.

Fixes phpstan/phpstan#14551

…et in `ConstantArrayTypeBuilder`

- In `ConstantArrayTypeBuilder::setOffsetValueType`, when handling a union
  offset type (e.g. `'a'|'b'`) where all scalar types match existing keys,
  the builder always unioned the new value with the old value at each
  matching key. For optional keys, this is incorrect: the key's presence
  implies it was the target of the write, so the old value is stale and
  should be replaced, not unioned.
- The fix: when `$optional` is false and the matching key is in
  `$this->optionalKeys`, replace the value instead of unioning. Required
  keys still union correctly since their old values represent valid
  alternative states.
- Updated bug-11846.php assertions: the old test was asserting the buggy
  overapproximate behavior (`array{}|array{array{}}` instead of the
  correct `array{array{}}`).
@ondrejmirtes ondrejmirtes merged commit a5a5b0a into phpstan:2.1.x Apr 29, 2026
656 of 657 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-h8hz5mm branch April 29, 2026 08:39
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.

2 participants