Skip to content

Commit bdf955d

Browse files
VincentLangletphpstan-bot
authored andcommitted
Fix ErrorType leaking from array_key_exists with union key types in loops
When array_key_exists() was called with a union constant key type (e.g., 'c1'|'c2') on an empty array inside a loop, the NonEmptyArrayType narrowing was skipped due to a count(getConstantScalarTypes()) <= 1 guard. This caused the isIterableAtLeastOnce()->no() early return to produce empty SpecifiedTypes, leaving the array typed as array{} even in the true branch. Subsequent access to $arr[$key] on the empty array type produced ErrorType, which propagated through loop iterations and eventually caused false "Unable to resolve template type" errors when the array was passed to generic functions like array_values(). The fix removes the <= 1 guard so NonEmptyArrayType narrowing always applies when array_key_exists returns true, which is semantically correct regardless of key type. Closes phpstan/phpstan#14489
1 parent 77a3244 commit bdf955d

3 files changed

Lines changed: 56 additions & 12 deletions

File tree

src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,15 @@ public function specifyTypes(
7777
if ($context->true()) {
7878
$specifiedTypes = new SpecifiedTypes();
7979

80-
if (count($keyType->getConstantScalarTypes()) <= 1) {
81-
$nonEmptyType = $arrayType->isArray()->yes()
82-
? new NonEmptyArrayType()
83-
: TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType());
84-
$specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create(
85-
$array,
86-
$nonEmptyType,
87-
$context,
88-
$scope,
89-
));
90-
}
80+
$nonEmptyType = $arrayType->isArray()->yes()
81+
? new NonEmptyArrayType()
82+
: TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType());
83+
$specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create(
84+
$array,
85+
$nonEmptyType,
86+
$context,
87+
$scope,
88+
));
9189

9290
if ($arrayType->isIterableAtLeastOnce()->no()) {
9391
return $specifiedTypes;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14489;
4+
5+
use function array_key_exists;
6+
use function array_merge;
7+
use function array_unique;
8+
use function array_values;
9+
use function PHPStan\Testing\assertType;
10+
11+
function () {
12+
$data = [['c1' => [1], 'c2' => [4]]];
13+
14+
$cData = [];
15+
foreach ($data as $cMap) {
16+
foreach ($cMap as $c => $ids) {
17+
if (array_key_exists($c, $cData)) {
18+
$cData[$c] = array_unique(array_merge($cData[$c], $ids));
19+
} else {
20+
$cData[$c] = $ids;
21+
}
22+
}
23+
}
24+
25+
$values = array_values($cData);
26+
assertType('non-empty-list<non-empty-array<0|1, 1|4>>', $values);
27+
};
28+
29+
function () {
30+
/** @var 'c1'|'c2' $c */
31+
$c = 'c1';
32+
/** @var array{1}|array{4} $ids */
33+
$ids = [1];
34+
35+
$cData = [];
36+
while (rand(0, 1)) {
37+
if (array_key_exists($c, $cData)) {
38+
assertType('non-empty-array<\'c1\'|\'c2\', array{1}|array{4}>', $cData);
39+
assertType('array{1}|array{4}', $cData[$c]);
40+
$cData[$c] = $cData[$c];
41+
} else {
42+
$cData[$c] = $ids;
43+
}
44+
}
45+
assertType('array<\'c1\'|\'c2\', array{1}|array{4}>', $cData);
46+
};

tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ public function testBug7000b(): void
419419
{
420420
$this->analyse([__DIR__ . '/data/bug-7000b.php'], [
421421
[
422-
"Offset 'require'|'require-dev' might not exist on array{require?: array<string, string>, require-dev?: array<string, string>}.",
422+
"Offset 'require'|'require-dev' might not exist on non-empty-array{require?: array<string, string>, require-dev?: array<string, string>}.",
423423
16,
424424
],
425425
]);

0 commit comments

Comments
 (0)