Skip to content

Report ?? null / ??= null on an always-set left side as an unnecessary null coalesce#5865

Open
phpstan-bot wants to merge 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-fje3gw5
Open

Report ?? null / ??= null on an always-set left side as an unnecessary null coalesce#5865
phpstan-bot wants to merge 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-fje3gw5

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

hello1($name) ?? null is equivalent to hello1($name): a ?? (or ??=) with a null fallback only does something when the left side can be undefined. When the left side is always set, the coalesce never changes the result and is redundant. PHPStan did not report this. This change adds detection for it.

Changes

  • conf/config.neon, conf/bleedingEdge.neon, conf/parametersSchema.neon: new featureToggles.unnecessaryNullCoalesce (false by default, true under bleeding edge).
  • src/Rules/Variables/NullCoalesceRule.php: after the existing IssetCheck pass, run checkUnnecessaryNullCoalesce(). It reports nullCoalesce.unnecessary when:
    1. the right-hand operand's type is always null (isNull()->yes()), and
    2. the left-hand operand is always set, determined by MutatingScope::issetCheck($left, static fn (): bool => true) === true.
  • Removed redundant ?? null instances surfaced by the new rule in PHPStan's own code:
    • src/Analyser/NodeScopeResolver.php (array_last($stmt->cond) ?? null, $parameterType ?? null ×2)
    • src/Type/FileTypeMapper.php (several array_last(...) ?? null)
    • src/Reflection/SignatureMap/Php8SignatureMapProvider.php ($phpDocReturnType ?? null)
  • tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php: pass the toggle, add testBug4337, and account for the extra (correct) report in testBug14213.

Root cause

This is not a regression but a missing check. The existing NullCoalesceRule/IssetCheck only reported the left side being "not nullable" or "always null". The third redundant shape — left always set but nullable, right null — produced no diagnostic because IssetCheck::check() returns null for that case. The fix reuses the already-existing "always set" traversal (MutatingScope::issetCheck) and only adds the new diagnostic when the right side is itself always null.

Analogous cases

All of the following go through the single issetCheck(..., fn () => true) "always set" probe and are covered by one code path; each is exercised in tests/.../data/bug-4337.php:

  • function call hello1($name) ?? null (the reported case)
  • instance method call $foo->method() ?? null
  • static method call Foo::staticMethod() ?? null
  • always-defined nullable variable $x ?? null
  • always-set nullable property $foo->stringOrNull ?? null
  • always-set nullable static property Foo::$staticStringOrNull ?? null
  • always-set nullable array offset $arr['a'] ?? null
  • assignment form $x ??= null

Negative cases verified to not report: maybe-undefined variable, maybe-undefined array offset, and any ??/??= whose right side is not null. Nullsafe access ($obj?->prop ?? null) is already handled by the existing IssetCheck nullsafe branch, so it short-circuits before the new check and does not double-report.

Test

NullCoalesceRuleTest::testBug4337 analyses data/bug-4337.php (copied from the issue's example plus the analogous constructs) and asserts the eight expected nullCoalesce.unnecessary reports plus the three negative cases. Verified failing before the fix (no errors) and passing after. The full suite, make phpstan, make cs-fix all pass.

Fixes phpstan/phpstan#4337
Fixes phpstan/phpstan#12179
Fixes phpstan/phpstan#9966

Comment thread tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php
Comment thread src/Rules/Variables/NullCoalesceRule.php Outdated
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-fje3gw5 branch from c6159f0 to 2636415 Compare June 14, 2026 08:55
@VincentLanglet VincentLanglet self-assigned this Jun 14, 2026

@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.

It fixes more open issues which might need tests

@VincentLanglet VincentLanglet 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.

Add non regression test for

phpstan/phpstan#12179
phpstan/phpstan#9966

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed the reviewer's request to add non-regression tests for issues #12179 and #9966.

What I added

tests/PHPStan/Rules/Variables/data/bug-9966.php — the exact array shape from issue #9966 (array{key1: string, key2: string|null, key3?: string, key4?: string|null}):

  • $a['key1'] ?? null → existing "always exists and is not nullable" report (non-nullable offset)
  • $a['key2'] ?? null → new nullCoalesce.unnecessary report (always-set but nullable offset — the case the issue asked for)
  • $a['key3']/$a['key4'] (optional offsets) → correctly not reported

tests/PHPStan/Rules/Variables/data/bug-12179.php — issue #12179's "always-defined-but-nullable variable" case:

  • $name ?? null on a nullable parameter → reported as unnecessary
  • a maybe-undefined $x ?? null → correctly not reported

NullCoalesceRuleTest.php — added testBug12179 and testBug9966 with the expected error lines/messages.

Verification

  • Both new tests fail without the fix (toggle off → the nullCoalesce.unnecessary reports disappear) and pass with it — confirming they're genuine regression tests.
  • Full NullCoalesceRuleTest (34 tests) passes.
  • make phpstan reports no errors.

Committed as b7a4dac6c and pushed.

One note for the reviewer: I built the reproducers from the issue bodies (which include the exact array shape for #9966 and the "always-defined nullable variable" description for #12179), since the playground links are JS-rendered and weren't fetchable here.

VincentLanglet and others added 4 commits June 15, 2026 07:58
…ssary null coalesce

- Add a `unnecessaryNullCoalesce` feature toggle (off by default, enabled in bleedingEdge.neon).
- In `NullCoalesceRule`, after the existing isset-based check, report a `nullCoalesce.unnecessary` error when the right side of `??`/`??=` is always `null` and the left side is always set (so the coalesce can never change the result). "Always set" is determined via `MutatingScope::issetCheck()` with a trivial callback, which correctly handles function/method/static calls, defined nullable variables, always-set nullable properties and array offsets, etc.
- Clean up the redundant `?? null` instances this surfaced in PHPStan's own source (`NodeScopeResolver`, `FileTypeMapper`, `Php8SignatureMapProvider`), mostly `array_last(...) ?? null` and a few always-initialized locals.
…alues

Closes phpstan/phpstan#12179
Closes phpstan/phpstan#9966

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-fje3gw5 branch from b7a4dac to badef10 Compare June 15, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants