Skip to content

Commit ce6e5e2

Browse files
committed
Fix deeply nested array assignments marking keys as optional in loops
When building arrays with 3+ levels of nesting inside loops (e.g. $arr[$i][$j][$k]['key'] = value), keys like 'def' and 'ghi' were incorrectly marked as optional in the inferred type, while 2-level nesting worked correctly. The root cause was in ArrayType::setExistingOffsetValueType's fallback path. For non-constant array item types, it performed a raw union with the stale item type from loop stabilization, re-introducing intermediate type variants where keys were missing. The existing special path handled this correctly when the item type was a constant array (the 2-level case), but not when it was a general array type wrapping constant arrays (the 3+ level case). The fix adds a recursive path for when both the item type and value type are arrays whose inner value types are constant arrays. This delegates to the inner level's setExistingOffsetValueType, where the existing constant-array special path correctly handles per-key updates and optionality. Closes phpstan/phpstan#13637
1 parent 106fc93 commit ce6e5e2

File tree

3 files changed

+62
-1
lines changed

3 files changed

+62
-1
lines changed

src/Type/ArrayType.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,24 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
410410
}
411411
}
412412

413+
if (
414+
$this->itemType->isArray()->yes()
415+
&& $valueType->isArray()->yes()
416+
&& $this->itemType->getIterableValueType()->isConstantArray()->yes()
417+
&& $valueType->getIterableValueType()->isConstantArray()->yes()
418+
) {
419+
$newItemType = $this->itemType->setExistingOffsetValueType(
420+
$valueType->getIterableKeyType(),
421+
$valueType->getIterableValueType(),
422+
);
423+
if ($newItemType !== $this->itemType) {
424+
return new self(
425+
$this->keyType,
426+
$newItemType,
427+
);
428+
}
429+
}
430+
413431
return new self(
414432
$this->keyType,
415433
TypeCombinator::union($this->itemType, $valueType),

tests/PHPStan/Analyser/AnalyserIntegrationTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ public function testBug7581(): void
991991
public function testBug7903(): void
992992
{
993993
$errors = $this->runAnalyse(__DIR__ . '/data/bug-7903.php');
994-
$this->assertCount(24, $errors);
994+
$this->assertCount(23, $errors);
995995
}
996996

997997
public function testBug7901(): void
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13637;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @return array<int, array<int, array<int, array{abc: int, def: int, ghi: int}>>>
9+
*/
10+
function doesNotWork() : array {
11+
$final = [];
12+
13+
for ($i = 0; $i < 5; $i++) {
14+
$j = $i * 2;
15+
$k = $j + 1;
16+
$final[$i][$j][$k]['abc'] = $i;
17+
$final[$i][$j][$k]['def'] = $i;
18+
$final[$i][$j][$k]['ghi'] = $i;
19+
}
20+
21+
assertType("non-empty-array<int<0, 4>, non-empty-array<int<0, 8>, non-empty-array<int<1, 9>, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>", $final);
22+
23+
return $final;
24+
}
25+
26+
/**
27+
* @return array<int, array<int, array{abc: int, def: int, ghi: int}>>
28+
*/
29+
function thisWorks() : array {
30+
$final = [];
31+
32+
for ($i = 0; $i < 5; $i++) {
33+
$j = $i * 2;
34+
$k = $j + 1;
35+
$final[$i][$j]['abc'] = $i;
36+
$final[$i][$j]['def'] = $i;
37+
$final[$i][$j]['ghi'] = $i;
38+
}
39+
40+
assertType("non-empty-array<int<0, 4>, non-empty-array<int<0, 8>, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>", $final);
41+
42+
return $final;
43+
}

0 commit comments

Comments
 (0)