Skip to content

False positive and false negative with null coalescing assignment operator (??=) #15228

Open
phpstan/phpstan-src
#6860

Description

@westonruter

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 turns no into maybe, 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 to level: 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:

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:

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions