Skip to content

Fix phpstan/phpstan#14301: Unioned array shapes are no longer disjunctive#5229

Merged
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ox57lpy
Mar 16, 2026
Merged

Fix phpstan/phpstan#14301: Unioned array shapes are no longer disjunctive#5229
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ox57lpy

Conversation

@phpstan-bot
Copy link
Collaborator

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

This PR fixes phpstan/phpstan#14301 where count()-based type narrowing failed on unions of constant array shapes.

Problem

For a type like array{bool}|array{mixed, string|null, mixed}, checking count($row) !== 1 should narrow to array{mixed, string|null, mixed} in the truthy branch and array{bool} in the else branch. Instead, no narrowing occurred — the type remained array{bool}|array{mixed, string|null, mixed} in both branches.

Root cause: specifyTypesForCountFuncCall() correctly computed the narrowed type, but it was applied via addTypeToExpression() which intersects with the original type. TypeCombinator::intersect() couldn't filter union members because ConstantArrayType uses open-shape semantics — intersecting two constant arrays with different key counts doesn't produce never.

Similarly for issue #11488: array{mixed}|array{mixed, string|null, mixed} with count($row) !== 1 failed because array{mixed} is a supertype of array{mixed, string|null, mixed} in open-shape semantics, so both intersection and removal approaches fail.

Fix

For constant array list unions without optional keys, use HasOffsetValueType constraints instead of the computed result type:

  • sureType: HasOffsetValueType(sizeMin - 1) — intersecting with this keeps only arrays that have at least sizeMin keys (e.g., HasOffset(2) filters out 1-key arrays when narrowing for count === 3)
  • sureNotType: HasOffsetValueType(sizeMax) — removing this filters out arrays with more than sizeMax keys (e.g., removing HasOffset(1) filters out 3-key arrays when narrowing for count === 1)

This works because HasOffsetValueType correctly identifies whether a sealed constant array has a given offset (yes/no), unlike the open-shape intersection which considers extra keys compatible.

For arrays with optional keys, the existing behavior is preserved (falls through to the original create() path).

Changes

File Change
src/Analyser/TypeSpecifier.php Use HasOffsetValueType constraints for count narrowing on constant array list unions without optional keys
tests/PHPStan/Analyser/nsrt/bug-14301.php Regression test for phpstan/phpstan#14301

@staabm staabm requested a review from VincentLanglet March 16, 2026 15:16
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.

Also try to fix phpstan/phpstan#11488 at the same time. Add a regression test and make sure your fix fixes everything at once.

$specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr);
if ($specifiedTypes !== null) {
if ($argType->isConstantArray()->yes()) {
return $specifiedTypes->setAlwaysOverwriteTypes();
Copy link
Member

Choose a reason for hiding this comment

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

setAlwaysOverwriteTypes is a hack that should not be used, and often makes the code worse and more buggy.

By default, SpecifiedTypes are intersected (or removed) from the type in Scope. setAlwaysOverwriteTypes makes it so that the type in SpecifiedTypes takes over the original Type in Scope, possibly losing information.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I processed this review but have nothing to report.

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.

Try the review again, I made your timeout longer.

Also rebase on top of latest 2.1.x in phpstan/phpstan-src and fix conflicts.

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.

Try the review again, I made your timeout longer.

Also rebase on top of latest 2.1.x in phpstan/phpstan-src and fix conflicts.

@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-ox57lpy branch 2 times, most recently from 6c5f267 to c858ed2 Compare March 16, 2026 17:46
@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-ox57lpy branch from c858ed2 to 7e6348d Compare March 16, 2026 17:51
@ondrejmirtes ondrejmirtes merged commit 69d0151 into phpstan:2.1.x Mar 16, 2026
101 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-ox57lpy branch March 16, 2026 17:52
@phpstan-bot
Copy link
Collaborator Author

I processed this review but have nothing to report.

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