Skip to content

Preserve non-empty array guarantee in ConstantArrayType::setOffsetValueType when union key expansion produces all-optional keys#5567

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

Preserve non-empty array guarantee in ConstantArrayType::setOffsetValueType when union key expansion produces all-optional keys#5567
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-pgxlyqb

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Since 2.1.52, iterating over a non-empty-list<'a'|'b'> with foreach and building an array like $result[$key] = 1 produced array{a?: 1, b?: 1} (possibly empty) instead of non-empty-array{a?: 1, b?: 1}. The root cause was that ConstantArrayType::setOffsetValueType did not ensure the result was non-empty when the ConstantArrayTypeBuilder expanded a union key into individual optional entries.

Changes

  • src/Type/Constant/ConstantArrayType.php: In setOffsetValueType, after getting the builder result, intersect with NonEmptyArrayType when the result's isIterableAtLeastOnce() is not yes. This mirrors what ArrayType::setOffsetValueType already does (it always adds NonEmptyArrayType to the intersection).
  • tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php: Updated assertion from array{1?: true, 2?: true} to non-empty-array{1?: true, 2?: true} — this test was asserting the buggy behavior.
  • tests/PHPStan/Analyser/nsrt/bug-14552.php: New regression test covering non-empty-list foreach, possibly-empty list foreach, three-key union, direct assignment with string union key, direct assignment with integer union key, and setting union key on an already non-empty array.

Root cause

Commit 2f66c45 ("Preserve constant array when assigning a union of scalars") changed ConstantArrayTypeBuilder::setOffsetValueType to expand union scalar keys into individual optional entries even when the starting array is empty. This produced precise array shapes like array{a?: 1, b?: 1}, but each key was marked optional (we don't know WHICH key will be set), so the ConstantArrayType allowed emptiness. The constraint that "at least one key will definitely be set" was lost.

ArrayType::setOffsetValueType already handles this correctly by always including NonEmptyArrayType in the result intersection (lines 367–383 of ArrayType.php). The ConstantArrayType path was missing this guarantee.

Analogous cases investigated

  • ArrayType::setOffsetValueType: Already adds NonEmptyArrayType — no fix needed.
  • IntersectionType::setOffsetValueType: Delegates to member types, which handle it correctly — no fix needed.
  • UnionType::setOffsetValueType: Delegates to each union member — no fix needed.
  • ConstantArrayType::setExistingOffsetValueType: Different semantics (updates existing key value, doesn't add new elements). The non-emptiness is inherited from the source type's structure — no fix needed.
  • Accessory type implementations (NonEmptyArrayType, OversizedArrayType): Return $this — no fix needed.

Test

Regression tests in tests/PHPStan/Analyser/nsrt/bug-14552.php:

  • nonEmptyListForeach: Foreach over non-empty-list<'a'|'b'> — verifies result is non-empty-array{a?: 1, b?: 1}
  • possiblyEmptyListForeach: Foreach over list<'a'|'b'> — verifies result is array{}|array{a?: 1, b?: 1}
  • nonEmptyListThreeKeys: Three-key union variant
  • directAssignment: Direct $arr[$key] = 1 with string union key (no foreach)
  • directAssignmentIntKey: Integer union key variant
  • setOnNonEmptyArray: Setting union key on an array that already has a required key

Fixes phpstan/phpstan#14552

…lueType` when union key expansion produces all-optional keys

- `ConstantArrayTypeBuilder` now expands union scalar keys (e.g. `'a'|'b'`)
  into individual optional entries even for empty arrays (since 2f66c45).
  Each key is optional because we don't know which branch of the union will
  be taken, but at least one WILL be set, so the result must be non-empty.
- `ArrayType::setOffsetValueType` already intersects with `NonEmptyArrayType`,
  but `ConstantArrayType::setOffsetValueType` did not, causing the array shape
  `array{a?: 1, b?: 1}` to be reported as possibly empty.
- Add `NonEmptyArrayType` intersection in `ConstantArrayType::setOffsetValueType`
  when the builder result's `isIterableAtLeastOnce()` is not `yes`.
- Update pre-existing test that asserted the buggy (possibly-empty) behavior.
@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-pgxlyqb branch from 53fa52c to 1b83541 Compare April 29, 2026 10:20
@ondrejmirtes ondrejmirtes merged commit c8106c3 into phpstan:2.1.x Apr 29, 2026
652 of 655 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-pgxlyqb branch April 29, 2026 10:24
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