Skip to content

Fix phpstan/phpstan#14297: Regression: type narrowing on non-empty-list array shape#5227

Merged
ondrejmirtes merged 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-9r3apu4
Mar 16, 2026
Merged

Fix phpstan/phpstan#14297: Regression: type narrowing on non-empty-list array shape#5227
ondrejmirtes merged 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-9r3apu4

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When a constant array type with optional keys (e.g., non-empty-list{0?: 'a'|'b', 1?: 'b'}) was narrowed using a count() check like count($a) === 2, the true branch correctly narrowed to the specific shape, but the else branch kept the original unnarrowed type instead of narrowing to the complementary shape.

Changes

  • Added falsey context handling in specifyTypesForCountFuncCall() in src/Analyser/TypeSpecifier.php — for constant array types, computes the remaining possible size by removing the checked size from the array's size range, then recursively calls with the negated (truthy) context to build the narrowed type
  • Updated test expectations in tests/PHPStan/Analyser/nsrt/list-count.php for the improved else-branch narrowing behavior
  • Added array_values() in src/Reflection/InitializerExprTypeResolver.php and src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php to preserve list types for constructor parameters affected by the more precise narrowing

Root cause

The specifyTypesForCountFuncCall() method had a continue that skipped array types in the falsey context when isSizeSuperTypeOfArraySize was maybe. This meant constant arrays with optional keys — where the size is a range rather than exact — got no narrowing in the else branch. The fix computes what count values remain possible after excluding the checked value, and then reuses the existing truthy narrowing logic to build the correct narrowed type.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14297.php which reproduces the original issue: after filtering and counting a list with optional keys, verifying that count($a) === 2 in the else branch correctly narrows to the single-element shape.

Fixes phpstan/phpstan#14297

- Handle falsey context in specifyTypesForCountFuncCall for constant arrays
  by computing the remaining size range and recursing with negated context
- Update test expectations in list-count.php for improved narrowing
- Add array_values() to UnionType/IntersectionType constructor calls that
  lost list type due to more precise count narrowing
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14297.php

Closes phpstan/phpstan#14297
Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

In src/Reflection/InitializerExprTypeResolver.php and src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php you added array_values calls.

I suspect it's because make phpstan started showing errors.

But in these cases it's obvious the array is already a list. Fix the analyser so that these calls are not needed. And remove them so that the diff doesn't touch any unrelated code to the actual type inference and narrowing.

… optional keys

When ConstantArrayTypeBuilder builds arrays with optional keys at sequential
positions, its nextAutoIndexes tracking degrades isList to "maybe" even though
the result is definitively a list. Fix this by calling makeList() on the built
array when we know the original type was a list.

This removes the workaround array_values() calls in InitializerExprTypeResolver
and ArrayChangeKeyCaseFunctionReturnTypeExtension that were needed because the
analyser incorrectly inferred those arrays might not be lists.

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

Done. Here's what I did:

Root cause: When specifyTypesForCountFuncCall builds narrowed constant arrays with optional keys using ConstantArrayTypeBuilder, the builder's nextAutoIndexes tracking degrades isList from yes to maybe. This happens because after an optional key at position N, the next key at N+1 falls between min(nextAutoIndexes) and max(nextAutoIndexes), which the builder interprets as potentially creating a gap.

Fix (in src/Analyser/TypeSpecifier.php): After the builder produces the array, if we know the original type was a list ($isList->yes()) but the builder degraded it (!$builder->isList()), call makeList() on the resulting ConstantArrayType to restore the list flag.

Removed workarounds: Reverted the array_values() calls in InitializerExprTypeResolver.php and ArrayChangeKeyCaseFunctionReturnTypeExtension.php since they're no longer needed — the analyser now correctly recognizes those arrays as lists.

@ondrejmirtes ondrejmirtes enabled auto-merge (squash) March 16, 2026 12:56
@ondrejmirtes ondrejmirtes merged commit 677d53f into phpstan:2.1.x Mar 16, 2026
649 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-9r3apu4 branch March 16, 2026 12:57
@phpstan-bot
Copy link
Collaborator Author

I processed this review but have nothing to report.

@mvorisek
Copy link
Contributor

Please see phpstan/phpstan#11488 (comment), this change does not seem to be right.

@ondrejmirtes
Copy link
Member

@mvorisek Please open a new bug report and show what this has broken. You have a few hours until release 😊

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