Correctly mark all unpacked constant array items as optional in array_merge/array_replace#5525
Merged
ondrejmirtes merged 1 commit intoApr 24, 2026
Conversation
…y_merge`/`array_replace` - Fix `$optionalArgTypesOffset` calculation in both `ArrayMergeFunctionDynamicReturnTypeExtension` and `ArrayReplaceFunctionReturnTypeExtension`: track start index before adding items to `$argTypes` instead of computing a wrong offset from `count($argTypes) - 1` after adding. The old formula only marked the last item as optional when unpacking a multi-element constant array union with an empty variant, leaving earlier items incorrectly marked as required. - In the allConstant code path, use `$optionalArgTypes` to mark keys from optional args as optional in `ConstantArrayTypeBuilder`, so the result correctly allows an empty array. - In the non-constant code path, skip optional args when building `$offsetTypes` so that `HasOffsetType`/`HasOffsetValueType` accessory types are not added for keys that may not exist. - Same fix applied to `ArrayReplaceFunctionReturnTypeExtension` which had identical code.
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
When
array_merge(...$values)orarray_replace(...$values)was called where$valueswas a union of a multi-element constant array and an empty array (e.g.array{array{foo: int}, array<string, int>}|array{}), PHPStan incorrectly reported the result asnon-empty-arraybecause it failed to mark all unpacked items as optional. The root cause was a wrong index calculation that only marked the last unpacked item as optional instead of all of them.Changes
src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php:$optionalArgTypesOffsetcalculation: save$startIndex = count($argTypes)before adding items, then use a simpleforloop from$startIndextocount($argTypes)to populate$optionalArgTypes$isOptionalArgtoConstantArrayTypeBuilder::setOffsetValueType()so keys from optional args are marked optional$offsetTypesto avoid adding incorrectHasOffsetType/HasOffsetValueTypeaccessory typessrc/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php:Probed other files using
$arg->unpack(SprintfFunctionDynamicReturnTypeExtension,MinMaxFunctionReturnTypeExtension,ArrayIntersectKeyFunctionReturnTypeExtension) — none use theoptionalArgTypespattern, so no fix needed.Root cause
The
$optionalArgTypesOffsetwas computed ascount($argTypes) - 1after adding items. When unpacking a constant array with N value types, this produced an offset ofN - 1, and iteratingarray_keys($argTypes)(indices0..N-1) yielded optional indices[N-1, N, ..., 2N-2]. Only indexN-1was valid — indices0..N-2were missed. This meant the firstN-1items from the unpack were never marked optional.When one of those non-optional items had
isIterableAtLeastOnce()->yes()(e.g. anon-empty-arrayorarray{foo: int}), the result was incorrectly marked asnon-empty-array. In the allConstant path, theConstantArrayTypeBuildernever checked$optionalArgTypesat all, so all keys were treated as required regardless.Test
tests/PHPStan/Analyser/nsrt/bug-14526.php: regression tests forarray_mergeandarray_replacewith:Fixes phpstan/phpstan#14526