Skip to content

Fix phpstan/phpstan#11565: False positive: Conditional return type takes the wrong branch#5362

Merged
staabm merged 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-b6qlhzl
Apr 24, 2026
Merged

Fix phpstan/phpstan#11565: False positive: Conditional return type takes the wrong branch#5362
staabm merged 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-b6qlhzl

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When a variable is reassigned from a function with a conditional return type (e.g., $items = iteratorToList($items)), PHPStan incorrectly resolved the conditional return type to never instead of list<T>. This happened because the TypeSpecifier used the post-assignment scope to evaluate the conditional, causing the variable's new type (the function's return type) to be used as the input, creating a circular evaluation.

Changes

  • src/Analyser/TypeSpecifier.php: In the null-context handling of Assign expressions, remove any specifiedTypes that target the assigned variable. This prevents the post-assignment type from incorrectly influencing the conditional return type evaluation.
  • src/Analyser/SpecifiedTypes.php: Added removeExpr(string $exprString) method to allow filtering out entries from both sureTypes and sureNotTypes by expression key.
  • tests/PHPStan/Analyser/nsrt/bug-11565.php: Regression test covering both the buggy case (same variable) and the working case (different variable).

Root cause

After processing $items = iteratorToList($items), the scope has $items typed as list<string>. The statement processing in NodeScopeResolver then calls specifyTypesInCondition with this post-assignment scope to extract additional type narrowing. For an Assign in null context, the TypeSpecifier recurses into the RHS function call. ParametersAcceptorSelector::selectFromArgs evaluates the argument $items using the post-assignment scope, getting list<string> instead of the original iterable<string, string>. Since list<string> IS a list, the conditional ($iterable is list ? never : list<T>) resolves to never, and the TypeSpecifier creates a "not list" narrowing for $items. When applied to list<string>, this produces *NEVER*.

The fix removes specifiedTypes targeting the assigned variable in null context, since after reassignment the variable no longer represents the function's input.

Test

Added tests/PHPStan/Analyser/nsrt/bug-11565.php which tests that $items = iteratorToList($items) correctly resolves to list<string> when $items starts as iterable<string, string>. Also verifies the existing working case with different variables.

Fixes phpstan/phpstan#11565

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.

Is it the only place the removeExpr is needed ?

  • Only if context is null ?
  • Only for Assign (And not other type of assign like ??=)

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what was done to address the review:

Changes made:

  1. src/Analyser/TypeSpecifier.php: Replaced the variable-only removeExpr guard (instanceof Expr\Variable && is_string(...)) with $this->exprPrinter->printExpr($expr->var). This handles any LHS expression type (variables, property fetches, array dim fetches, etc.) and removes the conditional — it always runs in null context.

  2. tests/PHPStan/Analyser/nsrt/bug-11565.php: Added a test case for same-variable reassignment inside an if condition (truthy context) to verify it works correctly.

Answers to the reviewer's questions:

  • Only null context? Yes — in non-null (truthy/falsy) context, the code recurses into $expr->var (the LHS variable), not $expr->expr (the RHS expression). The conditional return type is never re-evaluated, so the bug can't occur.

  • Only for Assign, not ??=? Yes — AssignOp\Coalesce (??=) is not handled in specifyTypesInCondition at all. In null context it falls through to returning empty SpecifiedTypes, so there's no recursion into the RHS where the bug could occur.

@VincentLanglet
Copy link
Copy Markdown
Contributor

@staabm I remembered you said something about $this->exprPrinter->printExpr($expr->var) (about how it was wrong to use it and how to fix it) ; do you remember something about it ?

@VincentLanglet VincentLanglet self-assigned this Apr 23, 2026
@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 24, 2026

I remembered you said something about $this->exprPrinter->printExpr($expr->var) (about how it was wrong to use it and how to fix it) ; do you remember something about it ?

I can remember that too much expression printing can be slow.


if ($context->null()) {
$specifiedTypes = $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr);
$specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var));
Copy link
Copy Markdown
Contributor

@staabm staabm Apr 24, 2026

Choose a reason for hiding this comment

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

why do we need this fix only for $context->null()?
shouldn't a assignment always override pre-existing types?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed. Here's the summary:

Changes made:

Added code comments in src/Analyser/TypeSpecifier.php explaining why removeExpr is only needed in the $context->null() branch, and cleaned up unnecessary comments in the test file.

Answer to the reviewer's question:

The removeExpr is only needed for $context->null() because the two branches recurse into different sub-expressions:

  • Null context (statement like $items = iteratorToList($items);): recurses into $expr->expr (the RHS). The RHS function call's conditional return type may produce specifiedTypes targeting the LHS variable, causing circular evaluation. removeExpr prevents this.
  • Non-null context (condition like if ($items = iteratorToList($items))): recurses into $expr->var (the LHS variable) to narrow based on truthiness. It does not recurse into the RHS, so conditional return types are never re-evaluated and the bug cannot occur. Applying removeExpr here would be wrong — it would remove the truthiness narrowing for the assigned variable.

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

if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
$specifiedTypes = $specifiedTypes->removeExpr('$' . $expr->var->name);
}
$specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this fix also affects assignments to expressions different than Variable. add a test which is affected by this change

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed. Added two test cases to tests/PHPStan/Analyser/nsrt/bug-11565.php that exercise the generalized removeExpr for non-Variable LHS expressions:

  1. Property fetch ($holder->items = iteratorToList($holder->items)) — verifies the conditional return type resolves correctly when the LHS is a property fetch
  2. Array dim fetch ($data['items'] = iteratorToList($data['items'])) — verifies the same for array dimension access

Both make phpstan and make tests pass with all 11708 tests green.

github-actions Bot and others added 4 commits April 24, 2026 08:40
… when variable is reassigned

- When processing `$items = iteratorToList($items)` as a statement, the TypeSpecifier
  evaluated the conditional return type using the post-assignment scope where `$items`
  was already `list<string>`, causing the condition `$iterable is list` to be true
  and the return type to resolve to `never`
- Added `removeExpr` method to SpecifiedTypes to filter out entries by expression key
- In TypeSpecifier, when processing an Assign in null context, remove specifiedTypes
  that target the assigned variable to prevent the post-assignment type from being
  incorrectly narrowed by the conditional return type evaluation
- New regression test in tests/PHPStan/Analyser/nsrt/bug-11565.php
…t test

Address review feedback: use exprPrinter->printExpr($expr->var) instead
of only handling simple variables. This covers property fetches, array dim
fetches, and other LHS expressions that could theoretically trigger the
same circular evaluation bug.

The fix is only needed in null context (standalone expression statements)
because non-null context recurses into $expr->var, not $expr->expr, so
the RHS conditional return type is never re-evaluated. AssignOp\Coalesce
(??=) is not handled in specifyTypesInCondition and falls through to
empty SpecifiedTypes, so it cannot trigger this bug.

Added test case for truthy context (inside if) to verify it works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In null context, we recurse into $expr->expr (the RHS), which may produce
specifiedTypes for the LHS variable via conditional return types — causing
circular evaluation. In non-null context, we recurse into $expr->var (the
LHS) instead, so conditional return types are never re-evaluated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Test that the generalized removeExpr (using exprPrinter instead of
variable-only check) correctly handles non-Variable LHS expressions
like property fetches and array dim fetches in assignments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-b6qlhzl branch from f33028b to c8b3293 Compare April 24, 2026 06:40
@VincentLanglet VincentLanglet requested a review from staabm April 24, 2026 07:21
@staabm staabm merged commit d7e37ec into phpstan:2.1.x Apr 24, 2026
657 of 660 checks passed
@staabm staabm deleted the create-pull-request/patch-b6qlhzl branch April 24, 2026 12:03
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