Skip to content

Narrow the match subject when its assigned result is later narrowed#5808

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-zxo8ff4
Jun 5, 2026
Merged

Narrow the match subject when its assigned result is later narrowed#5808
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-zxo8ff4

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

A narrowing applied to a variable assigned from an if/elseif/else chain is projected back onto the condition subject (e.g. after $class = ...; if ($class === null) return;, the subject $x is narrowed to the values that produced a non-null $class). The same did not happen when the value was produced by a match expression — the subject stayed mixed. This change makes $var = match (...) build the same subject↔result relationship as the if form.

Changes

  • src/Analyser/ExprHandler/MatchHandler.php: extracted getArmScopesAndTypes() from resolveType(). It returns, per reachable arm, [scope, bodyType] where scope has the match subject narrowed to that arm's condition. resolveType() now unions the body types from this method. The enum fast path is preserved, so huge-enum matches keep their performance.
  • src/Analyser/ExprHandler/AssignHandler.php: when the assigned expression of a $var = ... is a Match_, call processMatchForConditionalExpressionsAfterAssign(). It assigns each arm's body type to $var inside that arm's subject-narrowed scope, then folds the per-arm scopes together with MutatingScope::mergeWith($scope, true) in the same order an if/elseif/else chain would. The conditional-expression holders that fall out of the merge are lifted onto the real scope. Added a small mergeConditionalExpressions() helper. MatchHandler is now injected into AssignHandler.

Root cause

PHPStan records "conditional expression holders" that capture relationships like "when $class is 'some_class', $x is 'aa'". For if/elseif/else, these are produced automatically by MutatingScope::mergeWith() / createConditionalExpressions() because the assignment $class = … happens inside each branch scope where the subject is already narrowed. A match expression resolves to a single union before the assignment, so the assignment to $class happens outside the per-arm narrowed scopes and the relationship was lost. The fix reconstructs the per-arm narrowed scopes, performs the assignment inside them, and reuses the exact same merge machinery — guaranteeing match and if produce identical holders.

Test

tests/PHPStan/Analyser/nsrt/bug-14772.php covers, with assertType():

  • the reported case (mixed subject, narrowing the result to non-null narrows the subject to 'aa'|'bb'|'cc');
  • narrowing the result to a single arm value ('some_class''aa');
  • match (true) with === comparison arms;
  • int subject;
  • a non-null default arm (narrowing to the default value yields mixed~('aa'|'bb'));
  • two arms sharing the same body type (1|2);
  • an enum subject (Suit::Hearts).
    Each assertion fails before the fix (the subject stays mixed/int) and passes after, matching the if equivalent.

Fixes phpstan/phpstan#14772
Fixes phpstan/phpstan#10843

- Extract `MatchHandler::getArmScopesAndTypes()` from `resolveType()`, returning each reachable arm's body type together with the scope in which the match subject is narrowed to that arm's condition (preserving the enum fast path).
- In `AssignHandler`, for a `$var = match (...) { … }` assignment, assign each arm body type to `$var` inside the arm's subject-narrowed scope and merge the per-arm scopes with `MutatingScope::mergeWith()` exactly like an equivalent `if`/`elseif`/`else` chain. This reuses the existing conditional-expression machinery so the holders linking `$var` and the subject are identical to the `if` form.
- A later narrowing of the assigned result (e.g. `if ($class === null) return;`) now narrows the subject too, matching the long-standing behaviour of the `if` equivalent. Works for `mixed`, scalar, `int`, enum subjects, `match (true)` with comparison arms, and non-null defaults.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add regression test for phpstan/phpstan#10843

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed the review by adding the requested regression test for issue #10843.

What I did

Verified #10843 is actually fixed by this PR. Issue #10843 is the reverse direction of the PR's main case: instead of narrowing the result from the subject, here narrowing the subject ($date) should narrow the result ($a). I confirmed the PR's conditional-expression machinery handles both directions:

  • Before the fix (parent commit's source): $a inside if ($date instanceof HelloWorld) is 1|false
  • After the fix: $a narrows to 1

So this is a genuine regression test — it fails before the fix and passes after.

Added tests/PHPStan/Analyser/nsrt/bug-10843.php — the issue author's verbatim reproducer (the match (true) { $date instanceof HelloWorld => 1, default => false } case), with an assertType('1', $a) inside the if ($date instanceof HelloWorld) branch. nsrt files are auto-discovered by NodeScopeResolverTest.

Verified: NodeScopeResolverTest passes (1634 tests, including the new file). The change is a test-data file only — no src/ changes — so PHPStan self-analysis is unaffected.

Committed as a separate logical change ("Add regression test for #10843") and pushed to the PR branch.

@ondrejmirtes ondrejmirtes merged commit c83300d into phpstan:2.2.x Jun 5, 2026
660 of 667 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-zxo8ff4 branch June 5, 2026 10:26
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.

Narrow the subject of a match(...) clause based on the narrowing of its result Type is not resolvable via match and instanceof check

2 participants