Fix phpstan/phpstan#14407: !in_array with enum values incorrectly narrows to *NEVER*#5365
Fix phpstan/phpstan#14407: !in_array with enum values incorrectly narrows to *NEVER*#5365phpstan-bot wants to merge 6 commits intophpstan:2.1.xfrom
Conversation
…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); |
There was a problem hiding this comment.
For performance use a single intersect call at the end of the foreach
There was a problem hiding this comment.
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>
VincentLanglet
left a comment
There was a problem hiding this comment.
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>
|
Done. The changes:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
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>
|
Done. The refactoring moves the expensive |
VincentLanglet
left a comment
There was a problem hiding this comment.
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>
|
Done. Extracted the narrowing value type computation into a private |
VincentLanglet
left a comment
There was a problem hiding this comment.
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>
|
Background tests also completed successfully. All clear - the commit is pushed and both static analysis and tests pass. |
Summary
When
!in_array($x, $arr)is used where$xis an enum case and$arris a non-empty array that may contain different enum cases in different branches (e.g.,array{A}|array{B}), PHPStan incorrectly narrowed$xto*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
src/Type/Php/InArrayFunctionTypeSpecifyingExtension.phpto compute a "guaranteed value type" in the false context by intersecting the iterable value types of all union members of the array typearray{A}|array{B}, the intersection of value typesAandBisnever, so no narrowing occursarray{A, B}, behavior is unchanged since there's only one variantRoot cause
InArrayFunctionTypeSpecifyingExtensionused$arrayType->getIterableValueType()for narrowing in the false context. For aUnionTypeof 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.phpwith three test cases:array{A}|array{B}- needle should NOT be narrowed to*NEVER*array{A, B}- needle should correctly be narrowed to*NEVER*Fixes phpstan/phpstan#14407