Skip to content

Build an (unsealed) array shape in ArrayType::intersectKeyArray() when the other operand has known sealedness#5792

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-3gmnfd5
Jun 2, 2026
Merged

Build an (unsealed) array shape in ArrayType::intersectKeyArray() when the other operand has known sealedness#5792
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-3gmnfd5

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

array_intersect_key($options, ['a' => null, 'b' => null]) where $options is a
general array<mixed> was inferred as array<'a'|'b', mixed>, so passing the result
to a parameter typed array{a?: mixed, b?: mixed} reported a false positive — even
though the two types describe the same set of values. The result is now inferred as
the array shape array{a?: mixed, b?: mixed}.

Changes

  • src/Type/ArrayType.php: intersectKeyArray() now inspects
    $otherArraysType->getConstantArrays(). When all of those shapes have a definite
    sealedness (isUnsealed() is not maybe), it builds a ConstantArrayType via
    ConstantArrayTypeBuilder — each shape key becomes an optional offset (the general
    first array may or may not contain it) carrying the first array's value type. Keys
    that cannot intersect the first array's key type are skipped, and an unsealed extra
    of the other shape is reproduced with makeUnsealed() (its key narrowed to the first
    array's key type). The shape-building is performed in a new private helper
    intersectConstantArrayShape().
  • tests/PHPStan/Analyser/nsrt/array-intersect-key.php: updated expectations to the
    now-more-precise shapes (array{foo?: string}, array{17?: string},
    array{''?: string}, array{17: string}, array{17: 'foo'},
    list{0?: string, 1?: string}).
  • tests/PHPStan/Analyser/nsrt/bug-14747.php: new regression test.

Root cause

ArrayType::intersectKeyArray() only had two precise outcomes (empty array when keys
are disjoint, $this when the other key type covers ours) and otherwise fell back to a
general array<otherKeys, ourValue>. It never reconstructed an array shape from the
other operand's constant keys, so the optional-key information available on the right
side of array_intersect_key() was lost. The fix reconstructs that shape whenever the
other operand exposes constant array shapes with a known sealedness.

Probed analogous cases

  • The sibling intersectKeyArray() implementations on accessory array types
    (NonEmptyArrayType, HasOffsetType, HasOffsetValueType, OversizedArrayType,
    AccessoryArrayListType) return $this/MixedType and are combined by
    IntersectionType::intersectKeyArray(). With the new ArrayType shape they now
    compose correctly and even more precisely — e.g. non-empty-array<17, string>&hasOffset(17)
    intersected with [17 => 'bar'] is now array{17: string} (a required key) instead of
    the previous accessory-intersection form.
  • array_intersect_key is the only built-in routed through intersectKeyArray;
    array_diff_key/array_intersect_assoc/array_diff_assoc have no dedicated extension
    and cannot narrow to a bounded shape (their results keep unbounded keys), so they are
    not affected.
  • Non-bleeding-edge constant shapes report isUnsealed() = maybe, so the general-array
    behavior is preserved off bleeding edge.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14747.php reproduces the reported case
    (array{a?: mixed, b?: mixed}) plus literal-shape, out-of-range-key, and
    union-of-shapes variants. It fails before the fix and passes after.
  • Existing array-intersect-key.php assertions updated to the corrected, more precise
    results.

Fixes phpstan/phpstan#14747

…n the other operand has known sealedness

- When `array_intersect_key()`'s first argument is a general `ArrayType` and the
  other operand is one or more constant array shapes with a definite sealedness
  (`isUnsealed()` is `yes` or `no`, i.e. bleeding edge), `ArrayType::intersectKeyArray()`
  now produces a `ConstantArrayType` with each shape key as an optional offset and the
  first array's value type, instead of degrading to `array<keys, value>`.
- Unsealed extras of the other shape are carried over via `makeUnsealed()`, with the
  unsealed key narrowed to the first array's key type.
- Keys that cannot intersect the first array's key type are dropped.
- Non-bleeding-edge shapes (`isUnsealed()` is `maybe`) keep the previous general-array
  behavior, so the non-bleeding-edge path is unaffected.
- Updated array-intersect-key.php expectations to the more precise shapes
  (e.g. `array{foo?: string}`, `array{17: string}`, `list{0?: string, 1?: string}`).
@ondrejmirtes ondrejmirtes merged commit d260d4e into phpstan:2.2.x Jun 2, 2026
663 of 666 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-3gmnfd5 branch June 2, 2026 17:31
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.

array{a?: mixed, b?: mixed} expected, array<'a'|'b', mixed> given

2 participants