Skip to content

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

Open
phpstan-bot wants to merge 9 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 9 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.

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.

Remove the computeGuaranteedValueType method and move the implementation directly

…ngType

Remove the separate computeGuaranteedValueType method and move its
implementation directly into computeNeedleNarrowingType, as suggested
in review.

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

Done. Inlined computeGuaranteedValueType directly into computeNeedleNarrowingType, removing the separate method as requested. Tests (11708 passing) and static analysis (no errors) both pass. Commit pushed.

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.

When count($innerValueTypes) === 0 you should return null rather than $arrayValueType no ?

…pes is empty

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

Tests already confirmed passing — all 11708 tests green, static analysis clean. The review feedback has been addressed and pushed.

@VincentLanglet VincentLanglet self-assigned this Mar 31, 2026
Comment on lines +190 to +192
$innerTypes = $arrayType instanceof UnionType ? $arrayType->getTypes() : [$arrayType];
$innerValueTypes = [];
foreach ($innerTypes as $innerType) {
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.

Rather than iterating on innerTypes then constantArrays you could directly iterate on Type::getArrays

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 — both background tasks completed successfully (phpstan clean, no errors). The review feedback has been addressed, committed, and pushed.

Use $arrayType->getArrays() to iterate over array variants directly,
instead of manually decomposing union types and then calling
getConstantArrays(). This simplifies the iteration and avoids
instanceof UnionType checks.

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