Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Correctly mark all unpacked constant array items as optional in array_merge/array_replace#5525

Merged
ondrejmirtes merged 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-rhl91ua
Apr 24, 2026
Merged

Correctly mark all unpacked constant array items as optional in array_merge/array_replace#5525
ondrejmirtes merged 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-rhl91ua

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When array_merge(...$values) or array_replace(...$values) was called where $values was 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 as non-empty-array because 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:

    • Fixed $optionalArgTypesOffset calculation: save $startIndex = count($argTypes) before adding items, then use a simple for loop from $startIndex to count($argTypes) to populate $optionalArgTypes
    • In the allConstant path: pass $isOptionalArg to ConstantArrayTypeBuilder::setOffsetValueType() so keys from optional args are marked optional
    • In the non-constant path: skip optional args when building $offsetTypes to avoid adding incorrect HasOffsetType/HasOffsetValueType accessory types
  • src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php:

    • Applied the identical three fixes (this file had the same code pattern)
  • Probed other files using $arg->unpack (SprintfFunctionDynamicReturnTypeExtension, MinMaxFunctionReturnTypeExtension, ArrayIntersectKeyFunctionReturnTypeExtension) — none use the optionalArgTypes pattern, so no fix needed.

Root cause

The $optionalArgTypesOffset was computed as count($argTypes) - 1 after adding items. When unpacking a constant array with N value types, this produced an offset of N - 1, and iterating array_keys($argTypes) (indices 0..N-1) yielded optional indices [N-1, N, ..., 2N-2]. Only index N-1 was valid — indices 0..N-2 were missed. This meant the first N-1 items from the unpack were never marked optional.

When one of those non-optional items had isIterableAtLeastOnce()->yes() (e.g. a non-empty-array or array{foo: int}), the result was incorrectly marked as non-empty-array. In the allConstant path, the ConstantArrayTypeBuilder never checked $optionalArgTypes at all, so all keys were treated as required regardless.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14526.php: regression tests for array_merge and array_replace with:
    • Union of multi-element constant array and empty array (constant path)
    • Union with non-empty first element and empty outer array (non-constant path)
    • Union with single element and empty array (verifies single-element case was already correct)
    • Three-element constant array union with empty (verifies all three items become optional)

Fixes phpstan/phpstan#14526

…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.
@ondrejmirtes ondrejmirtes merged commit bb22ec9 into phpstan:2.1.x Apr 24, 2026
654 of 656 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-rhl91ua branch April 24, 2026 08:50
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