Skip to content

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

Closed
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-d18j57b
Closed

Fix phpstan/phpstan#14411: Incorrect type narrowing with dependent types#5371
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-d18j57b

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When variables are assigned in different branches of an if/else and then one is checked for null, PHPStan could incorrectly narrow the type of a dependent variable from the other branch. For example, checking $b !== null after $b was assigned in both branches would incorrectly narrow $a to non-null, even though $b could be non-null from the else branch where $a IS null.

Changes

  • Added a type disjointness check in src/Analyser/MutatingScope.php in the createConditionalExpressions() method
  • A variable is now only used as a type guard when its types from the two branches are disjoint (no overlapping values)
  • This prevents creating conditional expressions where the guard type can't actually discriminate which branch was taken

Root cause

The createConditionalExpressions() method creates conditional type relationships like "if $b has type X, then $a has type Y". These are used to propagate type narrowing across dependent variables. However, the method didn't check whether the guard variable's types from both branches were disjoint. When they overlapped (e.g., mixed~null from the if-branch and int|null from the else-branch share int), narrowing the merged type could produce the exact branch type even for values originating from the other branch, causing incorrect type narrowing.

The fix adds a check: $holder->getType()->isSuperTypeOf($theirExpressionTypes[$exprString]->getType())->no() — ensuring that the guard variable's types from both branches don't overlap before using it as a discriminator.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14411.php which reproduces the original issue: $a = get_mixed(), then $b assigned conditionally from $a or get_optional_int(), then checking $b !== null should not narrow $a to non-null.

Fixes phpstan/phpstan#14411

- In createConditionalExpressions(), type guards were created for variables
  whose branch types overlapped, allowing narrowing of one variable to
  incorrectly narrow a dependent variable from a different branch
- Added a disjointness check: a variable is only used as a type guard when
  its "our" branch type and "their" branch type don't overlap
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14411.php
Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

  1. Please add regression tests for phpstan/phpstan#10843, phpstan/phpstan#14211, phpstan/phpstan#11328, phpstan/phpstan#10085

  2. You broke Sylius:


 ------ -------------------------------------------------------------------------------------------------------------------------------------------------------------- 
  Line   Sylius/Bundle/ShopBundle/EventListener/OrderCustomerIpListener.php                                                                                            
 ------ -------------------------------------------------------------------------------------------------------------------------------------------------------------- 
  52     Variable $order might not be defined.                                                                                                                         
         🪪  variable.undefined                                                                                                                                        
         ✏️  https://github.com/Sylius/Sylius/blob/da59129e4c249cdc3d4b57840c86880008315592/src/Sylius/Bundle/ShopBundle/EventListener/OrderCustomerIpListener.php#L52  
 ------ -------------------------------------------------------------------------------------------------------------------------------------------------------------- 

Please reproduce the problem (a test that passes without changes to src/ but fails with current src/ changes) and then fix it too.

phpstan-bot and others added 2 commits March 31, 2026 19:30
…sions

The type disjointness check in createConditionalExpressions() was too
aggressive, rejecting type guards when isSuperTypeOf returned Maybe for
object types. For class/interface combinations (e.g. Event vs
OrderInterface), the Maybe result is purely theoretical (some hypothetical
class could be both), whereas for non-object types (e.g. mixed~null vs
int|null, or non-empty-array vs array) it indicates genuine value overlap.

The fix now only skips guards when isSuperTypeOf returns yes (complete
containment) or when it returns maybe AND at least one type is not an
object type. Object-to-object Maybe results (interface/class combos) are
preserved as valid guards.

Adds integration test reproducing the Sylius OrderCustomerIpListener
pattern where $order is assigned in separate instanceof branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add NSRT regression tests for:
- phpstan/phpstan#10843: match(true) with instanceof type narrowing
- phpstan/phpstan#14211: repeated if($m) check with isset-based bool
- phpstan/phpstan#11328: backed enum in_array conditional narrowing
- phpstan/phpstan#10085: list type incorrectly inferred as non-empty

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

Both commits have been pushed. Here's a summary of what was done:

Fix (commit 1): The original isSuperTypeOf check was too broad - it rejected type guards whenever isSuperTypeOf returned Maybe, which incorrectly prevented object type guards like Event vs OrderInterface from working. The fix now:

  • Still skips guards when isSuperTypeOf returns yes (complete type containment)
  • Still skips guards when isSuperTypeOf returns maybe AND at least one type is NOT an object type (genuine value overlap for arrays, mixed types, etc.)
  • Preserves guards when isSuperTypeOf returns maybe for two object types (theoretical class/interface overlap that shouldn't invalidate the guard)

This fixes the Sylius regression where $order was reported as "might not be defined" while keeping the original #14411 fix intact.

Regression tests (commit 2): Added NSRT tests for:

  • #10843 - match(true) with instanceof (pre-existing issue, test documents current behavior)
  • #14211 - repeated if ($m) with isset-based bool (passes)
  • #11328 - backed enum in_array conditional narrowing (passes)
  • #10085 - list type incorrectly inferred as non-empty (passes)

@VincentLanglet VincentLanglet deleted the create-pull-request/patch-d18j57b branch April 4, 2026 21:20
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.

3 participants