Skip to content

Keep an explicit key of an unsealed shape as an optional never key when subtracting its offset - #6792

Merged
ondrejmirtes merged 1 commit into
2.3.xfrom
issue-15428
Oct 23, 2026
Merged

ondrejmirtes merged 1 commit into
2.3.xfrom
issue-15428

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

ConstantArrayType::tryRemove() subtracted HasOffsetType / HasOffsetValueType by unsetting the offset. On an unsealed shape whose extras can hold that offset, dropping the explicit key lets the extras supply it again with their value type, so the result of the subtraction is wider than the original:

  • array{a?: string, ...} minus hasOffset('a') was array{...}
  • array{a?: string, b: int, ...<string, int>} minus hasOffset('a') was array{b: int, ...<string, int>}, which allows 'a' to be an int

The falsey isset() narrowing from 77527df (and ??, which reuses it) and the falsey array_key_exists() narrowing both go through this subtraction. When the widened branch merged back, the subject collapsed to array.

The key now stays as an optional never key, the same representation as the PHPDoc array{a?: never, ...}. The key is absent, and the extras still can't supply it, so branch merges restore the original shape:

before after
!isset($c['a']) array{...} array{a?: never, ...}
after $c['a'] ?? '' / the if array array{a?: string, ...}
!array_key_exists('a', $c), after the if array array{a?: string, ...}
!isset($d['a']) on array{a?: string, b: int, ...<string, int>} array{b: int, ...<string, int>} array{a?: never, b: int, ...<string, int>}

Sealed shapes, shapes whose extras cannot hold the offset (e.g. ...<int, int>), and legacy shapes without sealedness information behave exactly as before. A key that is only implied by the extras is still left alone, because unsetting it never widened anything.

This is an alternative to #6711. That PR skips the isset() narrowing for unsealed shapes in DefaultNarrowingHelper, but tryRemove() itself still widens, so array_key_exists() and the typed-extras case above stay broken.

The turbo mirror in turbo-ext/src/ConstantArrayType.cpp is updated the same way. Test results are identical with and without the extension loaded.

Closes phpstan/phpstan#15428

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xj1fBpXNVT34mRk1GQBxJF

…en subtracting its offset

ConstantArrayType::tryRemove() subtracted HasOffsetType and
HasOffsetValueType by unsetting the offset. On an unsealed shape whose
extras can hold that offset, dropping the explicit key lets the extras
supply it again with their value type, so the "subtracted" shape is wider
than the original: array{a?: string, ...} minus hasOffset('a') was
array{...}, and array{a?: string, b: int, ...<string, int>} re-admitted
'a' as int. The falsey isset() narrowing from 77527df and the falsey
array_key_exists() narrowing both go through this subtraction, and merging
the widened branch back collapsed the subject to array.

The key now stays as an optional never key instead - absent, but still
fencing the offset off from the extras - and branch merges restore the
original shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xj1fBpXNVT34mRk1GQBxJF
@ondrejmirtes
ondrejmirtes merged commit bddf27c into 2.3.x Oct 23, 2026
285 of 286 checks passed
@ondrejmirtes
ondrejmirtes deleted the issue-15428 branch October 23, 2026 15:15
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.

Precise unsealed array shape type is lost after ??

1 participant