Skip to content

Fix phpstan/phpstan#14411: Incorrect type narrowing with dependent types#5373

Merged
ondrejmirtes merged 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ysei247
Apr 1, 2026
Merged

Fix phpstan/phpstan#14411: Incorrect type narrowing with dependent types#5373
ondrejmirtes merged 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ysei247

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented Mar 31, 2026

Summary

PHPStan incorrectly reported Strict comparison using === between mixed and null will always evaluate to false when checking if a variable was null inside a conditional block that tested a different, independently assigned variable for null.

The bug occurred because createConditionalExpressions() created conditional type dependencies between all variables that differ between if/else branches, even when the guard type was ambiguous (overlapping with the other branch's type for the guard variable).

Changes

  • Modified createConditionalExpressions() in src/Analyser/MutatingScope.php to skip creating conditional expressions when the guard type overlaps with the other branch's type for the guard expression
  • The check verifies that $guardType->isSuperTypeOf($theirType) returns no before creating the conditional, ensuring the guard uniquely identifies the branch
  • Applied the same check to both loops in the method (regular variables and variables not in our branch)
  • Added regression test tests/PHPStan/Analyser/nsrt/bug-14411.php

Root cause

When merging scopes from if/else branches, createConditionalExpressions() creates conditional expressions like "if $b is type_from_branch1, then $a is type_from_branch1". This fires when a condition narrows $b to match the guard type.

In the reported case:

  • Branch 1 ($a !== null): $a: mixed~null, $b: mixed~null (from $b = $a)
  • Branch 2 (else): $a: null, $b: int|null (from $b = get_optional_int())

After merging, the guard "if $b is mixed~null" was created. When $b !== null was checked, $b became mixed~null, matching the guard and incorrectly narrowing $a to mixed~null. But $b could have gotten its non-null value from get_optional_int() in branch 2 (where $a WAS null).

The fix adds a check: if the guard type (mixed~null) is a supertype of any values from the other branch's type for the guard variable (int|null includes int which is within mixed~null), the guard is ambiguous and the conditional expression is not created.

Test

The regression test reproduces the exact scenario from the issue: two branches assign $b from different sources ($a vs get_optional_int()), and then checks that $a can still be null inside if ($b !== null).

Fixes phpstan/phpstan#14411
Fixes phpstan/phpstan#11328
Fixes phpstan/phpstan#10085
Fixes phpstan/phpstan#14211

- Fixed createConditionalExpressions() in MutatingScope creating incorrect
  conditional type guards when the guard type overlaps with the other branch's
  type for the same expression
- Added check: skip creating conditional expression when the guard type is a
  supertype of the other branch's type (isSuperTypeOf is not no), meaning the
  guard cannot uniquely identify which branch was taken
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14411.php

Fixes phpstan/phpstan#14411
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

No. Propose a fix without reverting the PR #5056

Restore preserveVacuousConditionals from PR phpstan#5056 and add a targeted
guard overlap check in createConditionalExpressions() to prevent
creating ambiguous conditional expressions when the guard type overlaps
with the other branch's type for the guard variable.

This preserves the improved type narrowing from phpstan#5056 (bug phpstan#4173,
#12992, #14227) while also fixing the incorrect dependent type
narrowing reported in #14411.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The commit restores PR #5056's preserveVacuousConditionals feature while adding a targeted guard overlap check in createConditionalExpressions() to fix #14411. Specifically:

  1. Restored from PR Fix phpstan/phpstan#4173: Consequest scope with the same condition #5056: preserveVacuousConditionals parameter on mergeWith(), the preserveVacuousConditionalExpressions() method, passing true from NodeScopeResolver during if/else merging, and the related test files (bug-12992, bug-14227, bug-5051 assertions, dynamic-access expectations)

  2. Added for #14411 fix: Guard overlap check in both loops of createConditionalExpressions() — skips creating a conditional expression when the guard type overlaps with the other branch's type (!$guardHolder->getType()->isSuperTypeOf($theirExpressionTypes[$guardExprString]->getType())->no()), preventing ambiguous conditional dependencies

  3. Kept bug-14411 regression test from the previous commit

All 11,709 tests pass and make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

You have a regression with

interface OrderInterface {}

class Event
{
	/** @return mixed */
	public function getSubject()
	{
		return new \stdClass();
	}
}

function getOrder(Event|OrderInterface $event): OrderInterface
{
	if ($event instanceof Event) {
		$order = $event->getSubject();
		assert($order instanceof OrderInterface);
	}

	if ($event instanceof OrderInterface) {
		$order = $event;
	}

	\PHPStan\Testing\assertVariableCertainty(\PHPStan\TrinaryLogic::createYes(), $order);

	return $order;
}

…sions()

The previous guard overlap check was too aggressive - it blocked creating
conditional expressions in both loops of createConditionalExpressions(),
including cases needed for variable certainty tracking.

The fix:
1. Only apply the guard overlap check in the first loop (type narrowing),
   not the second loop (variable certainty/existence)
2. Additionally require that the guarded expression exists with certainty
   Yes in the other branch before skipping - this ensures we only skip
   when both branches define the variable (ambiguous type case), not when
   a variable is only defined in one branch (certainty tracking case)

Adds regression test for the case reported by VincentLanglet where
$order should be certainly defined after two sequential if-blocks that
together cover all cases of a union type parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The fix addresses VincentLanglet's regression by refining the guard overlap check in createConditionalExpressions():

  1. Removed the check from the second loop — the second loop handles variable existence/certainty (creating ErrorType with TrinaryLogic::createNo()), and blocking those conditionals breaks variable certainty tracking.

  2. Added a requirement in the first loop that the guarded expression ($exprString) must exist with certainty Yes in the other branch before skipping. This distinguishes:

    • Bug 14411 case: $a exists in both branches → guard overlap check applies → ambiguous conditional is skipped (correct)
    • Regression case: $order only exists in one branch → check doesn't apply → conditional is created → variable certainty works correctly

All 11,710 tests pass and make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

make tests also confirmed: all 11,713 tests pass. Everything is committed and pushed.

@VincentLanglet
Copy link
Copy Markdown
Contributor

NB: @ondrejmirtes phpstan/phpstan#10843 is not solved by this (or your WIP PR)

@ondrejmirtes
Copy link
Copy Markdown
Member

Perfect, thank you!

@ondrejmirtes ondrejmirtes merged commit 4e6a1a8 into phpstan:2.1.x Apr 1, 2026
652 of 656 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-ysei247 branch April 1, 2026 06:52
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.

4 participants