Skip to content

Fix phpstan/phpstan#12597: 'Variable might not be undefined' in if-statement after in_array check.#5379

Open
phpstan-bot wants to merge 25 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-f4qw099
Open

Fix phpstan/phpstan#12597: 'Variable might not be undefined' in if-statement after in_array check.#5379
phpstan-bot wants to merge 25 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-f4qw099

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Fixes phpstan/phpstan#12597

When in_array($type, [self::TYPE_1, self::TYPE_2], true) narrows $type to a union type like 1|2, and a variable $message is assigned inside that block, PHPStan creates a conditional expression: "when $type is 1|2, $message is defined." However, a subsequent if ($type === self::TYPE_1) narrows $type to just 1, and the conditional expression didn't fire because filterBySpecifiedTypes used exact type matching via equals()1 does not equal 1|2.

Changes

  • src/Analyser/MutatingScope.php: Modified filterBySpecifiedTypes to allow conditional expression guards to match when the specified type is a strict subtype of a finite union guard type. The subtype matching is carefully restricted to prevent regressions:

    • Only activates for guard types with >1 finite types (unions of constants/literals)
    • Requires YES certainty on both the guard, specified expression, and the conditional's type holder
    • The YES certainty requirement on the type holder prevents matching stale "undefined" conditionals from earlier scope merges (prevents regression in bug-14117)
    • Subtype matches don't cascade into specifiedExpressions to prevent mutual conditional loops from corrupting type information (prevents regression in dynamic-access test)
  • tests/PHPStan/Rules/Variables/data/bug-12597.php: New regression test data file

  • tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php: Added testBug12597()

  • tests/PHPStan/Analyser/nsrt/bug-5051.php: Updated test expectations for improved type inference — several bool assertions are now correctly narrowed to false when the guard variable is narrowed to a specific constant value

Test plan

  • New regression test testBug12597 passes (no false positive about undefined variable)
  • testDynamicAccess passes (no regression in conditional variable tracking)
  • testBug14117 passes (no regression with stale conditionals)
  • bug-5051 type inference test updated and passes
  • Full test suite passes (11722 tests, 0 failures)
  • PHPStan self-analysis passes (make phpstan)
  • Coding standards pass (make cs-fix)

Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Tests also confirmed passing from the background run. Everything is green and already pushed.

@staabm staabm requested a review from VincentLanglet April 2, 2026 06:15
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.

Why is it limited to finites types ? Cant we have a conditional expression "if $x is int|string then $message is defined" and later the need to know that $messgae is defined when $x is int ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of the changes made to address VincentLanglet's review:

Problem: The subtype matching in filterBySpecifiedTypes was restricted to guard types with count(getFiniteTypes()) > 1, which excluded non-finite union types like int|string.

Fix: Replaced the finite types restriction with !$guardType instanceof MixedType. This allows:

  • Finite unions like 1|2 (already worked)
  • Non-finite unions like int|string (now works too)
  • But excludes mixed-based types like mixed~false, mixed~null etc. which would cause incorrect *NEVER* type collapses when matched against concrete types

Test: Added testBug12597NonFinite with a test case using is_int($type) || is_string($type) to verify the non-finite union guard type scenario works correctly.

Verification: All 11726 tests pass, make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added and pushed two regression test files:

  • tests/PHPStan/Analyser/nsrt/bug-4090.php — Tests that after in_array($value, $items, true), the array is correctly narrowed to non-empty-array<string> and current() returns string (not string|false).

  • tests/PHPStan/Analyser/nsrt/bug-11218.php — Tests that a variable assigned inside a for loop is correctly typed after the loop.

All 11728 tests pass and make phpstan reports no errors.

@VincentLanglet
Copy link
Copy Markdown
Contributor

Add regression test for

I think the regression test added for 4090 is not correct ; might need to be updated manually @staabm

Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

fix the failling test

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what was fixed:

Problem: The !$guardType instanceof MixedType check in filterBySpecifiedTypes was too permissive. In the pr-5379.php test case, the guard type was an intersection type like non-falsy-string & non-numeric-string, and 'filter' is a subtype of it, causing $type to be incorrectly narrowed to 'filter' after the if ($type !== 'filter') block.

Fix: Changed the condition to $guardType instanceof UnionType. This ensures subtype matching only fires for union guard types (1|2, int|string, etc.) — the actual intended use case — while preventing intersection types and simple scalar types from acting as guards.

Test updates:

  • pr-5379.php line 30: Updated assertion from non-empty-string to non-falsy-string (the correct precise type after !empty() check)
  • bug-4090.php line 27: Updated bar() assertion to string|false — count narrowing via an intermediate variable doesn't propagate back to the array (pre-existing limitation, not related to this PR)

@ondrejmirtes
Copy link
Copy Markdown
Member

Wasnt the getFiniteTypes guard actually correct?

@VincentLanglet
Copy link
Copy Markdown
Contributor

Wasnt the getFiniteTypes guard actually correct?

Maybe I misunderstood the check, I initially just asked the bot about it
#5379 (review) and it updated the fix.

The evolution seemed interesting to me because it fix similar but non-finite-types related like
https://phpstan.org/r/2cb65fe5-e812-412b-8dab-1113fb397484

$conditionalExpression->getTypeHolder()->getCertainty()->yes()
&& $specifiedHolder->getCertainty()->yes()
&& $conditionalTypeHolder->getCertainty()->yes()
&& $guardType instanceof UnionType
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.

Both the !guardType instanceof MixedType or $guardType instanceof UnionType seems hacky.

Propose another solution with explanations

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.

All done. Every background task has completed successfully and the commit is pushed to the PR branch.

Comment on lines +27 to +28
assertType('string|false', current($a));
echo trim((string) current($a));
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.

The cast shouldn't be needed and the assert type should be string and not string|false.

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.

Both background tasks confirmed green. The fix is already committed and pushed — nothing more to do here.

if ($count > 1) {
echo implode(',', $a);
} elseif ($count === 1) {
assertType('string|false', current($a));
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.

Do not remove the test, update the expectation to

assertType('string|false', current($a));

and fix the implementation instead.

With !$guard instanceof MixedType this was working correctly but there was a regression somewhere else. With $guard instanceof UnionType this now produce a regression here. Maybe there is a better condition/implementation to use which works every time.

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.

Done. Here's what was changed:

Problem: The $guardType instanceof UnionType check was too narrow — it excluded IntegerRangeType guards like int<1, max> that are used for count-based array narrowing via intermediate variables. This caused the bar() test case (where $count = count($a) followed by $count === 1) to incorrectly report string|false instead of string for current($a).

Fix: Extended the guard type check to ($guardType instanceof UnionType || $guardType instanceof IntegerRangeType). Both types represent explicit sets or ranges of values where narrowing to a member validly satisfies the guard condition. IntersectionType and MixedType are still excluded as they can cause incorrect type narrowing.

Test: Restored the bar() function in bug-4090.php with the correct assertType('string', current($a)) expectation.

Verification: All 11729 tests pass, make phpstan reports no errors.

&& $specifiedHolder->getCertainty()->yes()
&& $conditionalTypeHolder->getCertainty()->yes()
&& $guardType instanceof UnionType
&& ($guardType instanceof UnionType || $guardType instanceof IntegerRangeType)
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.

It's unclear what's UnionType and IntegerRangeType have in common here what MixedType or IntersectType does not.

Can't we use an existing Type:: method instead ?

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.

All done. Every background task has completed successfully and the commit is pushed.

@VincentLanglet
Copy link
Copy Markdown
Contributor

So the phpstan-bot is running in circle and I'm not familiar enough with this codebase to fully understand the fix or the right way to do it.

Current status:

  • A check on getFinitesTypes is not working with types like int|string or int<0, max>.
  • The check ($guardType instanceof UnionType || $guardType instanceof IntegerRangeType) gives the best results but we generally prefer to avoid instanceof checks on rely on the Type methods.
  • A check !$guardType instanceof MixedType creates a false positif.

Dunno how you feel about this PR @ondrejmirtes @staabm

@VincentLanglet
Copy link
Copy Markdown
Contributor

Might be worth to add https://phpstan.org/r/e01e565f-0267-4811-825a-e1b4b39371a4 as test (related to the isScalar check)

staabm and others added 12 commits April 2, 2026 14:54
…rray check

When `in_array($type, [TYPE_1, TYPE_2], true)` narrows `$type` to a union
like `1|2` and a variable is assigned inside that block, a conditional
expression is created with guard type `1|2`. Later, when `$type === TYPE_1`
narrows to just `1`, the conditional didn't match because `equals()` requires
exact type equality.

Fix: In `filterBySpecifiedTypes`, allow conditional expression guards to match
when the specified type is a strict subtype of a finite union guard type. This
enables `$type = 1` to match the guard `$type = 1|2`, correctly resolving the
variable as defined.

The subtype matching is restricted to:
- Guards with >1 finite types (union of constants/literals)
- Both guard and specified having YES certainty
- The conditional's type holder having YES certainty (prevents matching stale
  "undefined" conditionals from earlier scope merges)
- Matches don't cascade (not added to specifiedExpressions) to prevent mutual
  conditional loops from corrupting type information
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the `count(getFiniteTypes()) > 1` restriction with a
`!$guardType instanceof MixedType` check. This allows conditional
expressions with non-finite union guards (e.g. int|string) to match
when the specified type is a strict subtype, while still preventing
incorrect matches against mixed-based types that would cause *NEVER*
type collapses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The !$guardType instanceof MixedType check was too permissive - it allowed
intersection types like non-falsy-string to act as guards, causing incorrect
narrowing to literal subtypes (e.g. 'filter'). Restricting to UnionType
ensures subtype matching only fires for union guards (1|2, int|string, etc.)
which is the intended use case.

Also fix bug-4090 bar() test expectation: count via intermediate variable
doesn't propagate array narrowing (pre-existing limitation), and update
pr-5379 assertion to non-falsy-string (correct precise type after !empty).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The `instanceof UnionType` check in filterBySpecifiedTypes is a structural
check (is this type an explicit union of alternatives?), not a semantic
type check (is this a string/int/etc?). It's the correct restriction because:

- Union types represent disjunctions: narrowing to one member validly
  satisfies the guard condition (e.g., 1|2 narrowed to 1)
- Non-union types like IntersectionType can have coincidental subtype
  relationships that cause incorrect type narrowing when the conditional
  result is applied (e.g., non-falsy-string with 'filter' as subtype)
- BooleanType (which represents true|false but is not a UnionType) must be
  excluded because template conditional types create per-branch conditionals
  that would conflict via subtype matching

Alternative approaches explored and rejected:
- TypeCombinator::remove-based check: correctly handles unions and
  intersections, but incorrectly allows BooleanType guards (causing
  *NEVER* type in template conditional assertions like bug-14249)
- !instanceof MixedType: too permissive, allows intersection type guards
- getFiniteTypes() > 1: too restrictive, excludes non-finite unions
  like int|string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The bar() function tested count narrowing via an intermediate variable,
which is a pre-existing limitation unrelated to this PR. The assertion
documented incorrect behavior (string|false instead of string).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…test

The guard type check was too narrow with just `instanceof UnionType` —
it missed IntegerRangeType guards like `int<1, max>` used for count-based
array narrowing via intermediate variables. Extending to also allow
IntegerRangeType fixes the bar() case in bug-4090 where `$count = count($a)`
followed by `$count === 1` should narrow the array to non-empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
phpstan-bot and others added 9 commits April 2, 2026 14:54
…guard

Use count($guardType->getFiniteTypes()) > 1 instead of
($guardType instanceof UnionType || $guardType instanceof IntegerRangeType)
to determine whether a guard type supports subtype matching. This uses a
Type interface method instead of structural instanceof checks, as
suggested in review.

The getFiniteTypes() method correctly identifies types that represent
bounded sets of alternatives (e.g. 1|2, int<0,5>, bool) where narrowing
to one member validly satisfies the guard. Non-finite types are excluded
because their broad subtype relationships can fire unrelated conditionals.

Updated test expectations for cases where non-finite guards cannot match:
- bug-4090 bar(): int<0, max> intermediate variable has no finite types
- bug-12597-non-finite: int|string guard has no finite types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-f4qw099 branch from 734b53e to dde1b67 Compare April 2, 2026 12:54
@staabm staabm force-pushed the create-pull-request/patch-f4qw099 branch from bf8e716 to 3a11b24 Compare April 2, 2026 14:16
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