Skip to content

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

Merged
VincentLanglet merged 15 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-k4otjc0
Apr 1, 2026
Merged

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

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented Mar 31, 2026

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
Fixes phpstan/phpstan#13421
Fixes phpstan/phpstan#8864

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.

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);
	}
}

@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

@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

@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

@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

@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 ?

@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.

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 non regression test

/**
 * @param 'a'|'b'|'c' $x
 * @param non-empty-array<'a'|'b'> $a
 */
function testUnionWithOptionalKeys($x, $a): void
{
	assertType("non-empty-array<'a'|'b'>", $a);
	if (!\in_array($x, $a, true)) {
		assertType("'a'|'b'|'c'", $x);
	}
};

Because even non empty, a non-contant-array can only guarantee a value if it's the only one (ie non-empty-array<'a'>).

I don't think it's worth it to handle the general array case, so soolve this by just iterating on constantArrays.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Changes pushed:

  1. Test added: testNonConstantArray with non-empty-array<'a'|'b'> — verifies no narrowing occurs since no specific value is guaranteed
  2. Fix: For non-constant arrays in the else branch, only include the value type in $innerValueTypes when it has exactly one finite type (e.g., non-empty-array<'a'> can guarantee 'a'). Otherwise the array is skipped, which means if all arrays are non-constant with multiple value types, $innerValueTypes will be empty and null is returned (no narrowing).

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-k4otjc0 branch from 60bf3ff to 7dd643d Compare April 1, 2026 09:38
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.

Fix the static analysis build by updating the baseline

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added the instanceof ConstantArrayType deprecation error to the PHPStan baseline file. Both static analysis (make phpstan — no errors) and tests (make tests — 11708 passing) are green.

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 non regression test for:

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Tests background task also confirmed complete — 11710 tests passing. Everything is already committed and pushed.

@VincentLanglet VincentLanglet requested a review from staabm April 1, 2026 16:22

$filteredBars = array_filter($bars, fn (Bar $bar) => in_array($bar, $nonEmptyFilterArray));

assertType("array{0?: Bug13421\Bar::Bar1, 1?: Bug13421\Bar::Bar2, 2?: Bug13421\Bar::Bar3}", $filteredBars);
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 origin example contains a error like

Strict comparison using === between 0 and 0 will always evaluate to true

we should have a rule-test, this no longer happens.

use the tests/PHPStan/Analyser/nsrt/bug-13421.php file in the rule-test - don't create another copy of this file.

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.

Full test suite also passed. All 11710 tests green, static analysis clean. The review feedback from staabm has been addressed and pushed.


declare(strict_types = 1);

namespace Bug14407;
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 origin example contains a error like

Strict comparison using === between NEVER and SomeEnum::A will always evaluate to false.

we should have a rule-test, this no longer happens.

use the tests/PHPStan/Analyser/nsrt/bug-14407.php file in the rule-test - don't create another copy of this file.

phpstan-bot and others added 4 commits April 1, 2026 18:47
…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
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>
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>
…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 and others added 11 commits April 1, 2026 18:47
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>
…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>
…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>
…pes is empty

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

For non-constant arrays like `non-empty-array<'a'|'b'>`, we cannot
guarantee any specific value is present, so no narrowing should occur
in the false context. Only narrow when the value type has exactly one
finite type (e.g. `non-empty-array<'a'>`).

Added regression test for this case.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add rule tests for bug-14407 and bug-13421 to verify that the strict
comparison rule no longer reports false positive errors on these files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-k4otjc0 branch from a96e8e0 to 2b059ba Compare April 1, 2026 16:47
@VincentLanglet VincentLanglet merged commit fd42dc7 into phpstan:2.1.x Apr 1, 2026
652 of 655 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-k4otjc0 branch April 1, 2026 19:06
phpstan-bot added a commit to phpstan-bot/phpstan-src that referenced this pull request Apr 7, 2026
…rows to *NEVER* (phpstan#5365)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Langlet <vincentlanglet@hotmail.fr>
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