Skip to content

Resolve array offset access on never to never and treat never operands of ===/!== as undecided#5906

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-20i99q4
Open

Resolve array offset access on never to never and treat never operands of ===/!== as undecided#5906
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-20i99q4

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

An impossible identical comparison inside assert() over a constant array — e.g. assert($array[1] === null) where $array[1] is 0 — collapses the whole array to never (which is correct). The bug was that the following statements then produced a cascade of spurious Strict comparison using === between *NEVER* and null will always evaluate to false. errors, and that reading a further offset of the now-never array produced an *ERROR* type instead of never.

This was the regression behind the revert of d7ba1e3 ("More precise array-item types in loops"). This change fixes the root cause so the precision improvement no longer produces the cascade.

Changes

  • src/Analyser/ExprHandler/ArrayDimFetchHandler.php — short-circuit resolveType() when the offset-accessible type is NeverType, returning never. Previously, because never is a subtype of everything (including ArrayAccess), the fetch was routed through offsetGet() and produced *ERROR*.
  • src/Reflection/InitializerExprTypeResolver.phpresolveIdenticalType() now returns a non-constant BooleanType (instead of ConstantBooleanType(false)) when either operand is never, so ===/!== no longer reports always-true/false on already-unreachable code.
  • tests/PHPStan/Analyser/data/bug-9307.php — the loop case now correctly infers array<int, Bug9307\Item> instead of array<*ERROR*> (the inline comment already predicted this).
  • tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php — the last-match-arm expectations drop the two *NEVER* === 'ccc' reports, plus a new testBug14281.
  • Added regression tests tests/PHPStan/Analyser/nsrt/bug-14281.php and tests/PHPStan/Rules/Comparison/data/bug-14281.php.

Root cause

Two independent spots mishandled never:

  1. Offset read on never. ArrayDimFetchHandler checks (new ObjectType(ArrayAccess::class))->isSuperTypeOf($type)->yes() to decide whether to call offsetGet(). never satisfies that test (it is a subtype of every type), so an offset read on a never array was treated as an ArrayAccess::offsetGet() call and yielded *ERROR*. NeverType::getOffsetValueType() already returns never; the handler just never reached it.

  2. Identical comparison with a never operand. resolveIdenticalType() returned a constant false, which the StrictComparisonOfDifferentTypesRule / ImpossibleCheckType* rules report as "always false". A never operand denotes unreachable code that carries no comparable value, so the result should be undecided — consistent with how never already behaves as a boolean condition (if, &&, ||) and with NeverType::looseCompare() for ==/!=.

Test

  • nsrt/bug-14281.php asserts that after assert($array[1] === null) the array is *NEVER*, that $array[2] is *NEVER* (not *ERROR*), and that $i === null / $i !== null on a never variable are inferred as bool. It fails without the fix (Actual: *ERROR* and constant-boolean results).
  • Rules/Comparison/data/bug-14281.php + testBug14281 confirm the rule only reports the genuinely-impossible first comparisons (null === null, 0 === null, int !== int) and no longer reports the unreachable *NEVER* === null / *NEVER* !== null follow-ups.
  • Probed analogous comparisons: ==/!= already produce a non-constant boolean via NeverType::looseCompare(); <=>/</> produce never without an always-true/false report. No change needed for those.

Fixes phpstan/phpstan#14281

@staabm staabm force-pushed the create-pull-request/patch-20i99q4 branch from b5b4dbe to 661cf30 Compare June 21, 2026 15:46

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve conflicts and rebase

Comment on lines -689 to -692
[
"Strict comparison using === between *NEVER* and 'ccc' will always evaluate to false.",
38,
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need this errors to stay

…perands of `===`/`!==` as undecided

- ArrayDimFetchHandler::resolveType() now short-circuits when the
  offset-accessible type is NeverType. Because never is a subtype of
  everything (including ArrayAccess), the fetch was otherwise resolved
  through offsetGet() and produced an *ERROR* type instead of never.
- InitializerExprTypeResolver::resolveIdenticalType() returns a
  non-constant BooleanType (instead of ConstantBooleanType(false)) when
  either operand is never. A never-typed operand has no value to compare,
  so the comparison is undecided. This mirrors how never already behaves
  as a boolean condition (if/&&/||) and stops StrictComparison /
  ImpossibleCheck rules from piling always-true/false errors onto
  already-unreachable code.
- Together these let an impossible assertion such as
  `assert($array[1] === null)` collapse the array to never without
  emitting a cascade of `*NEVER* === ...` comparison errors on the
  following statements.
- Updated the last-match-arm rule test (the `*NEVER* === 'ccc'` reports
  are now suppressed) and bug-9307 (`array<*ERROR*>` is now correctly
  inferred as `array<int, Bug9307\Item>`).
- Probed siblings: `==`/`!=` already resolve to a non-constant boolean
  via NeverType::looseCompare(); `<=>`/`<`/`>` yield never without an
  always-true/false report, so no change was needed there.
@staabm staabm force-pushed the create-pull-request/patch-20i99q4 branch from 661cf30 to cae1545 Compare June 27, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants