Skip to content

Fix phpstan/phpstan#14407: !in_array with enum values incorrectly narrows to *NEVER*#5365

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

Fix phpstan/phpstan#14407: !in_array with enum values incorrectly narrows to *NEVER*#5365
phpstan-bot wants to merge 6 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-k4otjc0

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When !in_array($x, $arr) is used where $x is an enum case and $arr is a non-empty array that may contain different enum cases in different branches (e.g., array{A}|array{B}), PHPStan incorrectly narrowed $x to *NEVER*. This happened because the type specifier used the full union of all possible array values for narrowing, even though no single value was guaranteed to be present in every array variant.

Changes

  • Modified src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php to compute a "guaranteed value type" in the false context by intersecting the iterable value types of all union members of the array type
  • For union array types like array{A}|array{B}, the intersection of value types A and B is never, so no narrowing occurs
  • For non-union array types like array{A, B}, behavior is unchanged since there's only one variant

Root cause

InArrayFunctionTypeSpecifyingExtension used $arrayType->getIterableValueType() for narrowing in the false context. For a UnionType of arrays, getIterableValueType() returns the union of all possible values across all variants. But in the false context (!in_array), we can only safely remove values from the needle type that are guaranteed to be in every possible array variant. The fix computes this guaranteed type by intersecting value types across union members.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14407.php with three test cases:

  1. Union array array{A}|array{B} - needle should NOT be narrowed to *NEVER*
  2. Constant array array{A, B} - needle should correctly be narrowed to *NEVER*
  3. Possibly-empty array (from comment in issue) - no narrowing should occur

Fixes phpstan/phpstan#14407

…rows to *NEVER*

- In false context (!in_array), compute guaranteed array value type by
  intersecting value types across union members instead of using the
  full union of all possible values
- For array{A}|array{B}, neither A nor B is guaranteed in every variant,
  so no narrowing should occur
- For array{A, B}, both A and B are guaranteed, so narrowing to *NEVER*
  remains correct
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14407.php
if ($guaranteedValueType === null) {
$guaranteedValueType = $innerValueType;
} else {
$guaranteedValueType = TypeCombinator::intersect($guaranteedValueType, $innerValueType);
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.

For performance use a single intersect call at the end of the foreach

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. The change collects all inner value types into an array and passes them to a single TypeCombinator::intersect(...$innerValueTypes) call instead of intersecting incrementally in the loop, as requested by VincentLanglet. Tests and static analysis both pass.

Instead of incrementally intersecting types in a loop, collect all inner
value types and pass them to a single TypeCombinator::intersect() call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

Add test for union of constant array with optional keys.

/**
 * @param 'a'|'b'|'c' $x
 * @param array{a: 'a', c: 'c'}|array{a?:'a', b: 'b'} $a
 */
function test($x, $a): void
{
	assertType("array{a: 'a', c: 'c'}|array{a?: 'a', b: 'b'}", $a);
	if (!\in_array($x, $a, true)) {
		assertType("'a'|'b'|'c'", $x);
	}
}

When computing guaranteed values for the false context of in_array(),
exclude values from optional keys in constant arrays since they may
not be present at runtime. For array{a: 'a', c: 'c'}|array{a?: 'a', b: 'b'},
the value 'a' from the optional key should not be considered guaranteed.

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

Done. The changes:

  1. Test added: Union of constant arrays with optional keys (array{a: 'a', c: 'c'}|array{a?: 'a', b: 'b'}) - verifies no narrowing occurs since 'a' from the optional key isn't guaranteed to be present.

  2. Fix: When computing guaranteed values for !in_array narrowing, constant arrays now exclude values from optional keys. Uses getConstantArrays() (not instanceof) and isOptionalKey() to filter. For the test case, the second variant's guaranteed value is only 'b' (not 'a'|'b'), so intersecting with 'a'|'c' yields never, preventing incorrect narrowing.

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.

All this computation is only useful if we're in the case

count($needleType->getFiniteTypes()) > 0
&& $arrayType->isIterableAtLeastOnce()->yes()

So refactor to avoid the unnecessary computation

…n_array

Move the guaranteed value type computation inside the guard condition
that checks for finite types and non-empty array, so it's only
performed when actually needed.

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

Done. The refactoring moves the expensive $narrowingValueType computation (intersecting inner value types) inside the elseif branch where it's actually needed — guarded by count($needleType->getFiniteTypes()) > 0 && $arrayType->isIterableAtLeastOnce()->yes(). This avoids unnecessary computation when those preconditions aren't met. All tests and static analysis pass.

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.

Simplify this with a dedicated private method to compute the narrowingValueType in the context false

Extract the narrowing value type computation for the !in_array false
context into a dedicated private method for better readability.

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

Done. Extracted the narrowing value type computation into a private computeGuaranteedValueType() method, simplifying the specifyTypes() method. Tests (11708 passing) and static analysis (no errors) both pass.

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.

With a computeNeedleNarrowingValue method you could write

$narrowingValue = $this->computeNeedleNarrowingValue(...);
if (null !== computeNeedleNarrowingValue) {
    ...
}

This method would return arrayValueType in context true, and so on

…ext logic

Consolidates the needle narrowing logic into a single computeNeedleNarrowingType
method that returns the narrowing type or null. This eliminates duplicated
typeSpecifier->create + AlwaysRememberedExpr handling for both contexts.

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

Background tests also completed successfully. All clear - the commit is pushed and both static analysis and tests pass.

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.

2 participants