Skip to content

Do not narrow the left side of ?? to non-null when the coalesce context still permits a falsey value#6000

Merged
VincentLanglet merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-p7sp7v0
Jul 5, 2026
Merged

Do not narrow the left side of ?? to non-null when the coalesce context still permits a falsey value#6000
VincentLanglet merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-p7sp7v0

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

After a check like if (($arr[$key] ?? null) === false) { return; }, PHPStan wrongly memorized $arr[$key] as always existing. This produced false-positive isset.offset and nullCoalesce.offset errors ("Offset … always exists and is not nullable") on subsequent isset($arr[$key]) / $arr[$key] ?? … uses, even though $key may not be present in the array. This is a follow-up to #11708, which only fixed the preg_match variant.

The fix teaches CoalesceHandler not to narrow the left side of ?? to non-null when the surrounding condition only proves the coalesce is not exactly false — because a missing offset produces null, and null !== false.

Changes

  • src/Analyser/ExprHandler/CoalesceHandler.php: guarded the branch that narrows $expr->left to non-null with !$context->falsey(). That branch previously fired for any context carrying the TRUE bit, including the createFalse()->negate() ("anything but false") context, which still permits a null result.
  • tests/PHPStan/Rules/Variables/data/bug-13488.php: regression fixture based on the issue's playground sample, extended with the analogous !== false and falsey-right-operand (?? 0) cases.
  • tests/PHPStan/Rules/Variables/IssetRuleTest.php and NullCoalesceRuleTest.php: added testBug13488() expecting no errors (both rules were affected by the same wrong narrowing).

Root cause

EqualityTypeSpecifyingHelper::specifyTypesForConstantBinaryExpression() translates $x === false being false into a truthy-style narrowing by calling specifyTypesInCondition($x, createFalse()->negate()). That context (0b1011) means "anything but false" and correctly yields no narrowing for ordinary expressions via the default handler. However, CoalesceHandler::specifyTypes() treated any context with the TRUE bit as permission to strip null from the ?? left operand (and thus mark the offset/variable as set). For $arr[$key] ?? null, a missing offset makes the whole expression null, which satisfies !== false, so the offset cannot be assumed to exist.

The pattern — "not-false does not imply not-null/truthy" — affects every strict comparison that produces the not-false context:

  • ($arr[$key] ?? null) === false (reported case, false branch)
  • ($arr[$key] ?? null) !== false (true branch)
  • ($arr[$key] ?? 0) === false and any other falsey right operand of ??

All three are fixed by the single guard. The loose ==/!= variants were probed and are intentionally left untouched: null == false is true, so ($arr[$key] ?? null) != false genuinely does imply the offset exists.

Test

  • IssetRuleTest::testBug13488 and NullCoalesceRuleTest::testBug13488 run the reproducer at level with treatPhpDocTypesAsCertain and assert no errors. Both fail on the unfixed tree (they report the isset.offset / nullCoalesce.offset false positives) and pass with the fix.
  • The fixture also covers the analogous !== false and ?? 0 cases, which are false positives before the fix and clean afterwards.

Fixes phpstan/phpstan#13488

…text still permits a falsey value

- `CoalesceHandler::specifyTypes()` narrows the left operand of `??` to
  non-null (i.e. memorizes the offset/variable as existing) when the
  coalesce is truthy and the right operand is falsey. That branch was
  also reached for the "not false" context produced by `=== false` /
  `!== false` (`createFalse()->negate()`), which still permits the
  coalesce to be null (a missing offset yields null, and null !== false).
- Guard that narrowing with `!$context->falsey()` so it only fires when
  the context guarantees a non-null (truthy) result, matching the
  existing isset-guarded falsey branch.
- Fixes the false-positive `isset.offset` and `nullCoalesce.offset`
  "always exists and is not nullable" errors after
  `if (($arr[$key] ?? null) === false) { return; }`.
- Also fixes the analogous `!== false` (true branch) case and coalesces
  with a non-null but falsey right operand (`?? 0`); the loose `==`/`!=`
  variants were verified to remain correct, because `null == false` is
  true and therefore does legitimately imply the offset exists.
@VincentLanglet VincentLanglet requested a review from staabm July 4, 2026 19:04
@VincentLanglet VincentLanglet self-assigned this Jul 4, 2026

if ((new ConstantBooleanType(false))->isSuperTypeOf($scope->getType($expr->right)->toBoolean())->yes()) {
if (
!$context->falsey()

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.

I always mix up ->false and ->falsey.

would be great to also have tests for ==false, ==true

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. Here's what I did to address staabm's review:

Review point 1: "I always mix up ->false and ->falsey"

No code change needed — the guard correctly uses ->falsey(). I verified this empirically: reverting the guard makes the strict testBug13488 fail (it reports the false-positive isset.offset errors), and restoring it passes. The !$context->falsey() check is what blocks the createFalse()->negate() ("anything but false") context, which is exactly the buggy narrowing path. ->false() would be the wrong method here.

Review point 2: "would be great to also have tests for ==false, ==true"

Added a new fixture tests/PHPStan/Rules/Variables/data/bug-13488-loose.php covering all four loose variants, plus testBug13488Loose() in both IssetRuleTest and NullCoalesceRuleTest. I determined the correct expectations empirically rather than guessing:

Condition (true branch) Matches Offset must exist? Errors expected
($foo[$k] ?? null) == false false or null (missing) no none
($foo[$k] ?? null) == true true only yes isset.offset + nullCoalesce.offset
($foo[$k] ?? null) != false true only yes isset.offset + nullCoalesce.offset
($foo[$k] ?? null) != true false or null (missing) no none

This documents that loose comparison narrowing is correct (because null == false), which is why the fix intentionally left the ==/!= path untouched — the contrast the reviewer wanted to see spelled out.

Verification

  • testBug13488 (strict) and testBug13488Loose (loose) — 4/4 pass.
  • Full IssetRuleTest + NullCoalesceRuleTest — 66/66 pass.
  • make phpstan — no errors.

Committed as a separate logical change and pushed to the PR branch.

Loose == false / != true keep a coalesced offset possibly-missing
(null == false), while == true / != false imply it exists. Cover all
four variants for both the isset() and ?? left-side offset rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@VincentLanglet VincentLanglet merged commit b1c0eb2 into phpstan:2.2.x Jul 5, 2026
669 of 673 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-p7sp7v0 branch July 5, 2026 08:03
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.

nullCoalesce.offset and isset.offset report an error on an array offset that can be null

3 participants