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 Apr 29, 2026
Conversation
…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.
53fa52c to
1b83541
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Since 2.1.52, iterating over a
non-empty-list<'a'|'b'>withforeachand building an array like$result[$key] = 1producedarray{a?: 1, b?: 1}(possibly empty) instead ofnon-empty-array{a?: 1, b?: 1}. The root cause was thatConstantArrayType::setOffsetValueTypedid not ensure the result was non-empty when theConstantArrayTypeBuilderexpanded a union key into individual optional entries.Changes
src/Type/Constant/ConstantArrayType.php: InsetOffsetValueType, after getting the builder result, intersect withNonEmptyArrayTypewhen the result'sisIterableAtLeastOnce()is notyes. This mirrors whatArrayType::setOffsetValueTypealready does (it always addsNonEmptyArrayTypeto the intersection).tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php: Updated assertion fromarray{1?: true, 2?: true}tonon-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::setOffsetValueTypeto expand union scalar keys into individual optional entries even when the starting array is empty. This produced precise array shapes likearray{a?: 1, b?: 1}, but each key was marked optional (we don't know WHICH key will be set), so theConstantArrayTypeallowed emptiness. The constraint that "at least one key will definitely be set" was lost.ArrayType::setOffsetValueTypealready handles this correctly by always includingNonEmptyArrayTypein the result intersection (lines 367–383 ofArrayType.php). TheConstantArrayTypepath was missing this guarantee.Analogous cases investigated
ArrayType::setOffsetValueType: Already addsNonEmptyArrayType— 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.NonEmptyArrayType,OversizedArrayType): Return$this— no fix needed.Test
Regression tests in
tests/PHPStan/Analyser/nsrt/bug-14552.php:nonEmptyListForeach: Foreach overnon-empty-list<'a'|'b'>— verifies result isnon-empty-array{a?: 1, b?: 1}possiblyEmptyListForeach: Foreach overlist<'a'|'b'>— verifies result isarray{}|array{a?: 1, b?: 1}nonEmptyListThreeKeys: Three-key union variantdirectAssignment: Direct$arr[$key] = 1with string union key (no foreach)directAssignmentIntKey: Integer union key variantsetOnNonEmptyArray: Setting union key on an array that already has a required keyFixes phpstan/phpstan#14552