Repository navigation
False positive and false negative with null coalescing assignment operator (??=) #15228
Description
Bug report
In modernizing the WordPress core codebase, the use of the null coalescing assignment operator (??=) was introduced in WordPress/wordpress-develop@81e3c7f with the following change to src/wp-includes/class-wp-query.php:
@@ -3322,9 +3298,7 @@ public function get_posts() {
}
if ( 'ids' === $query_vars['fields'] ) {
- if ( null === $this->posts ) {
- $this->posts = $wpdb->get_col( $this->request );
- }
+ $this->posts ??= $wpdb->get_col( $this->request );
/** @var int[] */
$this->posts = array_map( 'intval', $this->posts );
@@ -3345,9 +3319,7 @@ public function get_posts() {
}
if ( 'id=>parent' === $query_vars['fields'] ) {
- if ( null === $this->posts ) {
- $this->posts = $wpdb->get_results( $this->request );
- }
+ $this->posts ??= $wpdb->get_results( $this->request );
$this->post_count = count( $this->posts );
$this->set_found_posts( $query_vars, $limits );Surprisingly, this caused two errors to be removed from our rule level 5 baseline:
------ -----------------------------------------------------------------------------------------------------------------------------------------------------------
Line src/wp-includes/class-wp-query.php
------ -----------------------------------------------------------------------------------------------------------------------------------------------------------
3420 Ignored error pattern #^Property WP_Query\:\:\$posts \(array<int\|WP_Post>\|null\) does not accept list<string\|null>\.$# (assign.propertyType) in path
.../src/wp-includes/class-wp-query.php is expected to occur 2 times, but occurred only 1 time.
🪪 ignore.count (non-ignorable)
at src/wp-includes/class-wp-query.php:3420
3427 Ignored error pattern #^Property WP_Query\:\:\$posts \(array<int\|WP_Post>\|null\) does not accept list<stdClass>\|null\.$# (assign.propertyType) in path
.../src/wp-includes/class-wp-query.php is expected to occur 2 times, but occurred only 1 time.
🪪 ignore.count (non-ignorable)
at src/wp-includes/class-wp-query.php:3427
------ -----------------------------------------------------------------------------------------------------------------------------------------------------------
To resolve this, we had to regenerate the baseline.
This appears to be due to a bug with how PHPStan is handling the null coalescing assignment operator, leading to unexpected false negatives and false positives for the assign.propertyType error.
False negative at rule level 6: https://phpstan.org/r/45dd93d8-4722-4bb1-b354-bf13285587e3
False positive at rule level 7: https://phpstan.org/r/3f5b46b2-1198-4ac9-a82d-430bfb090da3
🤖 The following was drafted with the assistance of Claude Opus 5.
Root cause
??= is parsed as an AssignOp node and handled by the generic read-modify-write machinery. For every AssignOp, the type handed to TypesAssignedToPropertiesRule is the type of the whole $lvalue OP= $value expression, not the type of the value that actually gets written. AssignOpHandler::processExpr() says so outright:
// applyWrite() emits nodes (PropertyAssignNode) whose rules ask about
// this whole `$lvalue OP= value` expression - store a before-scope
// anchored result first so those asks answer from the storage;And AssignOpHandler::resolveType() resolves the coalesce variant as the plain ?? binary operator:
if ($expr instanceof Expr\AssignOp\Coalesce) {
return $scope->getType(new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()));
}So the assigned type becomes nonNull($lvalue) | $value rather than just $value.
That is correct for +=, .=, *= and friends, where the expression's result is what gets written. ??= is the one AssignOp where the two diverge: the write happens only in the null branch, and only ever writes the right-hand side.
Why the symptom differs by rule level
checkUnionTypes flips to true at level 7 (conf/config.level7.neon), which changes the threshold in RuleLevelHelper::accepts():
return new RuleLevelHelperAcceptsResult($checkForUnion ? $accepts->yes() : !$accepts->no(), $accepts->reasons);- Levels 0-6 — an error is reported only when acceptance is
no. The widening turnsnointomaybe, so the error is suppressed entirely: a false negative. This is what removed the two entries from the WordPress baseline, since WordPress core is pinned tolevel: 5. - Levels 7+ — an error is reported unless acceptance is
yes. No false negative is possible here, because acceptance is monotone: if the union is accepted then so is the narrower right-hand side. However the reported type is wrong, and a false positive appears whenever the property's currently narrowed non-null type is not accepted by its declared type.
Code snippet that reproduces the problem
Two playground links for the same snippet, configured at the two relevant levels:
- Level 6 — the false negative: https://phpstan.org/r/45dd93d8-4722-4bb1-b354-bf13285587e3
- Level 7 — the false positive and the misleading type: https://phpstan.org/r/3f5b46b2-1198-4ac9-a82d-430bfb090da3
Each ??= is paired with the if (null === …) block it is shorthand for, so the two comparisons that matter are line 24 against line 30, and line 40 against line 47. The four lines carrying @phpstan-ignore assign.propertyType are the ones where an error is expected and correct; annotating them keeps each link's output down to just the anomaly.
<?php declare(strict_types = 1);
use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;
class CoalesceAssignDemo
{
public ?int $a = null;
/** @var list<int>|null */
public $b;
/** @return list<string> */
private function strings(): array
{
return ['x'];
}
// === Symptom 1: false negative below level 7 ===
// Both methods write a string into ?int - a guaranteed TypeError.
public function coalesceInvalid(): void
{
$this->a ??= 'boom'; // @phpstan-ignore assign.propertyType (NOT reported at levels 0-6.)
}
public function ifInvalid(): void
{
if (null === $this->a) {
$this->a = 'boom'; // @phpstan-ignore assign.propertyType (Reported from level 0. Correct.)
}
}
// === Symptom 2: false positive at level 7+ ===
// Both methods write list<int>, which the property accepts.
public function coalesceValid(): void
{
$this->b = $this->strings(); //@phpstan-ignore assign.propertyType (Genuine error, both methods.)
$this->b ??= [1, 2]; // VALID write - reported at 7+ anyway.
}
public function ifValid(): void
{
$this->b = $this->strings(); // @phpstan-ignore assign.propertyType (Genuine error, both methods.)
if (null === $this->b) {
$this->b = [1, 2]; // VALID write - correctly not reported.
}
}
}Both links report identical results on every PHP version from 7.2 to 8.5.
| Line | Code | Level 6 | Level 7 | Correct verdict |
|---|---|---|---|---|
| 24 | $this->a ??= 'boom'; |
no error ❌ | does not accept int|string |
error, does not accept string |
| 30 | $this->a = 'boom'; |
does not accept string |
does not accept string |
same ✅ |
| 40 | $this->b ??= [1, 2]; |
no error ✅ | does not accept list<1|2|string> ❌ |
no error |
| 47 | $this->b = [1, 2]; |
no error | no error | same ✅ |
Lines 39 and 45 are setup: an identical, genuine error in both methods, needed to give the property a narrowed type that its own declaration does not accept. They are ignored to keep the output minimal.
Line 24 is where the false negative shows up, and at level 6 it surfaces as PHPStan reporting No error with identifier assign.propertyType is reported on line 24. — the unmatched ignore is the tool stating that it found nothing on a line that writes a string into ?int. At level 7 the error does appear there, but typed int|string, when only string is ever written.
Line 40 is the false positive. The write is [1, 2], which the property accepts; the reported list<1|2|string> is a type that exists nowhere in the program, being the valid literal write fused with the string left over from line 39.
Expected output
$lvalue ??= $value should be checked exactly as the if (null === $lvalue) { $lvalue = $value; } form it is shorthand for, i.e. the type fed to the assignment rules should be the right-hand side alone:
- Line 24 reported from level 0, as
does not accept string, which makes its ignore match. - Line 40 not reported at any level.
- Lines 30, 39, 45 and 47 unchanged.
Both playground links should then report no errors at all.
Versions
Reproduced identically on 2.0.0, 2.2.13 and 2.2.14, at every level from 0 through 10. Behavior on 1.x could not be verified, as 1.10.67 and 1.11.0 report no errors at all for this file under PHP 8.5 and are therefore unreliable rather than evidence of absence.
Relation to existing issues
This is believed to be distinct from the two closest existing reports:
- Implicit isset check with null coalescing operator and default value fails #9946 concerns the
??operator on the read side — an implicitisset()narrowing not being recognized when the result is compared against the default. It does not involve the type being written. - Null coalesce assignment return value not handled correctly #9995 is in a sense the flip side of this report. Its fix is what makes
AssignOp\Coalesceresolve to the type of$var ?? $expr, which is correct for the expression's value. The defect here is thatTypesAssignedToPropertiesRulereuses that same resolver for the write check, where the answer should be the right-hand side alone. No revert of Null coalesce assignment return value not handled correctly #9995 is being suggested: the same expression simply needs to yield two different types depending on whether the value or the write is being asked about.
Code snippet that reproduces the problem
https://phpstan.org/r/45dd93d8-4722-4bb1-b354-bf13285587e3
Expected output
PHPStan should analyze the null coalescing assignment operator the same as the equivalent PHP code using a null check with an if statement.
Did PHPStan help you today? Did it make you happy in any way?
Testing without writing tests!
Activity