Skip to content

Narrow the right side of ??= with falsey isset() instead of !== null - #6145

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

Narrow the right side of ??= with falsey isset() instead of !== null#6145
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-c4kg951

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

$data['foo'] ??= $data['bar'] ?? null; on array{foo?: string, bar?: string} reported a bogus error — Coalesce operator ?? is unnecessary because the left side is always set and the right side is null. with treatPhpDocTypesAsCertain: false, and Offset 'bar' on *NEVER* on left side of ?? always exists and is not nullable. with it enabled. Both messages come from the same cause: while analysing the right-hand side of ??=, PHPStan narrowed $data down to *NEVER*.

The fix builds the right-hand-side scope from the actual ??= short-circuit condition (!isset($var)) instead of from $var === null.

Changes

  • src/Analyser/ExprHandler/AssignOpHandler.php — in processExpr(), the scope for $expr->expr is now $scope->filterByFalseyValue(new Expr\Isset_([$expr->var])) instead of $scope->filterByFalseyValue(new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null')))). Removed the now-unused ConstFetch / Name imports.
  • tests/PHPStan/Rules/Variables/data/bug-15021.php + NullCoalesceRuleTest::testBug15021() — the reproducer from the issue, expecting no errors.
  • tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php + NullCoalesceRuleTest::testNullCoalesceAssignRightSideScope() — the analogous left-hand-side forms.
  • tests/PHPStan/Analyser/nsrt/bug-15021.php — type-level assertions for every form.

Analogous cases fixed by the same change (each has a failing-before test):

  • optional array offset — array{foo?: string, bar?: string}, the reported case
  • non-constant array with a constant key — array<string, string>, previously non-empty-array<string, string>&hasOffsetValue('foo', *NEVER*)
  • non-constant array with nullable values — array<string, string|null>, right side saw null, now string|null (matching plain ??)
  • nested array offset — $data['a']['b'] ??= … on array{a?: array{b?: string, c?: string}}
  • non-nullable instance property — $foo->nonNullable ??= …
  • non-nullable static property — Foo::$staticNonNullable ??= …
  • array offset on a property — $foo->data['foo'] ??= …
  • undefined variable — $undefined ??= $undefined ?? 1 reported Variable $undefined on left side of ?? always exists and is always null., now correctly … is never defined.

Sibling constructs probed and found already correct, so left untouched:

  • src/Analyser/ExprHandler/CoalesceHandler.php — both resolveType() and processExpr() derive the right-side scope from the isset/coalesce condition, not from !== null
  • src/Analyser/ExprHandler/IssetHandler.php, src/Analyser/ExprHandler/EmptyHandler.php — already model "not set or null"
  • src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php, NullsafeMethodCallHandler.php — these use a truthy !== null filter, which is the correct semantics for ?-> (the object must be non-null), not a coalesce-style short circuit

Root cause

??= evaluates its right-hand side when the target is either unset or null. AssignOpHandler modelled only the second half by filtering the scope with the falsey value of $var !== null, i.e. asserting $var === null.

For every target whose type cannot hold null — an optional array offset typed string, a non-nullable property, an array<string, string> value — that assertion is unsatisfiable, so TypeSpecifier propagated the contradiction outwards and turned the containing expression into never: the whole $data array, the whole object, etc. Every rule and type query on the right-hand side then operated on *NEVER*, producing either cascading nonsense messages (Offset 'bar' on *NEVER' …) or, when PHPDoc types were not treated as certain, the "coalesce is unnecessary" verdict.

Using filterByFalseyValue(new Isset_([$var])) states the real condition. IssetHandler::specifyTypes() already knows how to narrow !isset(...) conservatively — it only removes a constant array when the offset is definitely set and definitely non-null (which is genuinely unreachable code), and it leaves possibly-missing offsets alone. This is also the exact condition CoalesceHandler::resolveType() uses for the right side of ??, so ??= and ?? now agree.

Test

  • NullCoalesceRuleTest::testBug15021() — verbatim reproducer from the issue's playground link; expects no errors. Fails before the fix with Offset 'bar' on *NEVER* on left side of ?? always exists and is not nullable.
  • NullCoalesceRuleTest::testNullCoalesceAssignRightSideScope() — covers property, static property, property offset, nested offset, non-constant array and undefined-variable targets. Before the fix it produced two extra Offset … on *NEVER* errors and the wrong message for the undefined variable.
  • tests/PHPStan/Analyser/nsrt/bug-15021.phpassertType() on the right-hand-side scope for all of the above; 7 assertions fail before the fix (5 of them *NEVER*).

Full make tests, make phpstan and make cs-fix are green.

Fixes phpstan/phpstan#15021

…null`

- `AssignOpHandler::processExpr()` built the scope for the right-hand side of `??=` with `filterByFalseyValue(new NotIdentical($var, null))`, i.e. it asserted `$var === null`. That is only one half of the `??=` condition: the right side also runs when `$var` is not set at all.
- Asserting `$var === null` on a target that can never hold `null` collapsed the whole containing type to `never`, so anything else read on the right side was analysed on a `*NEVER*` value (e.g. `Offset 'bar' on *NEVER* ...`) or reported as unnecessary.
- Replaced the condition with `filterByFalseyValue(new Isset_([$var]))`, which is exactly the `??=` short-circuit condition and matches what `CoalesceHandler::resolveType()` already uses for `??`.
- The single change fixes the whole family of left-hand-side forms, each covered by a new test: optional array offsets (`array{foo?: string}`), non-constant arrays with a constant key (`array<string, string>`, previously `non-empty-array&hasOffsetValue('foo', *NEVER*)`), nested offsets, non-nullable instance properties, non-nullable static properties, array offsets on a property, and undefined variables (`$undefined ??= $undefined ?? 1` reported "always exists and is always null" instead of "is never defined").
- Nullable targets keep the old, more precise narrowing to `null`, except for `array<string, T|null>` where the value is now `T|null` on the right side — consistent with what plain `??` already infers there.
- Probed the sibling constructs and left them unchanged: `CoalesceHandler` (`??`), `IssetHandler`, `EmptyHandler` and the nullsafe handlers already use the correct condition; the nullsafe handlers' `!== null` truthy filter is the right semantics for `?->`.
@VincentLanglet
VincentLanglet requested a review from staabm July 29, 2026 20:51
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.

False positive nullCoalesce.unnecessary on array with optional keys

2 participants