diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index f4e96ad8894..907d49eb40b 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -269,7 +269,11 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt continue; } - $valueTypes[$i] = TypeCombinator::union($valueTypes[$i], $valueType); + if (!$optional && in_array($i, $this->optionalKeys, true)) { + $valueTypes[$i] = $valueType; + } else { + $valueTypes[$i] = TypeCombinator::union($valueTypes[$i], $valueType); + } $offsetMatch = true; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11846.php b/tests/PHPStan/Analyser/nsrt/bug-11846.php index 4f67410ad3d..b4a3bc4bb1d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11846.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11846.php @@ -38,16 +38,16 @@ function demo2(array $idList): void $outerList[$id] = []; array_push($outerList[$id], []); } - assertType('non-empty-array{1?: array{}|array{array{}}, 2?: array{}|array{array{}}}', $outerList); + assertType('non-empty-array{1?: array{array{}}, 2?: array{array{}}}', $outerList); foreach ($outerList as $key => $outerElement) { $result = false; - assertType('array{}|array{array{}}', $outerElement); + assertType('array{array{}}', $outerElement); foreach ($outerElement as $innerElement) { $result = true; } - assertType('bool', $result); + assertType('true', $result); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14551.php b/tests/PHPStan/Analyser/nsrt/bug-14551.php new file mode 100644 index 00000000000..9b31fd2729a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14551.php @@ -0,0 +1,99 @@ + $keys + */ +function foreachTwoKeys(array $keys): void +{ + $result = []; + foreach ($keys as $k) { + $result[$k]['x'] = 1; + $result[$k]['y'] = 2; + } + + assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result); +} + +/** + * @param non-empty-list<'a'|'b'> $keys + */ +function foreachThreeKeys(array $keys): void +{ + $result = []; + foreach ($keys as $k) { + $result[$k]['x'] = 1; + $result[$k]['y'] = 2; + $result[$k]['z'] = 3; + } + + assertType("array{a?: array{x: 1, y: 2, z: 3}, b?: array{x: 1, y: 2, z: 3}}", $result); +} + +/** + * Test without foreach: union-key nested assignment + * @param 'a'|'b' $k + */ +function withoutForeach(string $k): void +{ + $result = []; + $result[$k]['x'] = 1; + $result[$k]['y'] = 2; + + assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result); +} + +/** + * Test with integer union keys + * @param 0|1 $k + */ +function integerKeys(int $k): void +{ + $result = []; + $result[$k]['x'] = 1; + $result[$k]['y'] = 2; + + assertType("array{0?: array{x: 1, y: 2}, 1?: array{x: 1, y: 2}}", $result); +} + +/** + * Test three-way union key + * @param 'a'|'b'|'c' $k + */ +function threeWayUnion(string $k): void +{ + $result = []; + $result[$k]['x'] = 1; + $result[$k]['y'] = 2; + + assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}, c?: array{x: 1, y: 2}}", $result); +} + +/** + * Required keys should still union (not replace) — existing behavior must be preserved + */ +function requiredKeysStillUnion(): void +{ + $result = ['a' => ['x' => 1], 'b' => ['x' => 1]]; + /** @var 'a'|'b' $k */ + $k = 'a'; + $result[$k]['y'] = 2; + + assertType("array{a: array{x: 1, y?: 2}, b: array{x: 1, y?: 2}}", $result); +} + +/** + * Non-nested union-key overwrites with optional keys + * @param 'a'|'b' $k + */ +function nonNested(string $k): void +{ + $result = []; + $result[$k] = 1; + $result[$k] = 2; + + assertType("non-empty-array{a?: 2, b?: 2}", $result); +}