From 576cfc8abe2bc9d83dc999bc564a78c74713a0f0 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:44:59 +0000 Subject: [PATCH] Build an (unsealed) array shape in ArrayType::intersectKeyArray() when the other operand has known sealedness - When `array_intersect_key()`'s first argument is a general `ArrayType` and the other operand is one or more constant array shapes with a definite sealedness (`isUnsealed()` is `yes` or `no`, i.e. bleeding edge), `ArrayType::intersectKeyArray()` now produces a `ConstantArrayType` with each shape key as an optional offset and the first array's value type, instead of degrading to `array`. - Unsealed extras of the other shape are carried over via `makeUnsealed()`, with the unsealed key narrowed to the first array's key type. - Keys that cannot intersect the first array's key type are dropped. - Non-bleeding-edge shapes (`isUnsealed()` is `maybe`) keep the previous general-array behavior, so the non-bleeding-edge path is unaffected. - Updated array-intersect-key.php expectations to the more precise shapes (e.g. `array{foo?: string}`, `array{17: string}`, `list{0?: string, 1?: string}`). --- src/Type/ArrayType.php | 48 ++++++++++++++++++ .../Analyser/nsrt/array-intersect-key.php | 14 +++--- tests/PHPStan/Analyser/nsrt/bug-14747.php | 50 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14747.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 2c9ee07301e..df8085d43db 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -503,9 +503,57 @@ public function intersectKeyArray(Type $otherArraysType): Type return $this; } + $constantArrays = $otherArraysType->getConstantArrays(); + if (count($constantArrays) > 0) { + // When the other operand is one or more array shapes with a known + // sealedness, the result is a (possibly unsealed) array shape too: + // it can only contain the keys present in those shapes, each + // optional because the general first array may or may not have it. + $allSealednessKnown = true; + foreach ($constantArrays as $constantArray) { + if ($constantArray->isUnsealed()->maybe()) { + $allSealednessKnown = false; + break; + } + } + + if ($allSealednessKnown) { + $results = []; + foreach ($constantArrays as $constantArray) { + $results[] = $this->intersectConstantArrayShape($constantArray); + } + + return TypeCombinator::union(...$results); + } + } + return $this->withTypes($otherArraysType->getIterableKeyType(), $this->getIterableValueType()); } + private function intersectConstantArrayShape(ConstantArrayType $constantArray): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $valueType = $this->getIterableValueType(); + $keyType = $this->getIterableKeyType(); + foreach ($constantArray->getKeyTypes() as $shapeKeyType) { + if (TypeCombinator::intersect($shapeKeyType, $keyType) instanceof NeverType) { + continue; + } + $builder->setOffsetValueType($shapeKeyType, $valueType, true); + } + + $unsealed = $constantArray->getUnsealedTypes(); + if ($constantArray->isUnsealed()->yes() && $unsealed !== null) { + $narrowedUnsealedKey = TypeCombinator::intersect($unsealed[0], $keyType); + if (!$narrowedUnsealedKey instanceof NeverType) { + $builder->makeUnsealed($narrowedUnsealedKey, $valueType); + } + } + + return $builder->getArray(); + } + public function popArray(): Type { return $this; diff --git a/tests/PHPStan/Analyser/nsrt/array-intersect-key.php b/tests/PHPStan/Analyser/nsrt/array-intersect-key.php index 33690634449..e3386d1f53a 100644 --- a/tests/PHPStan/Analyser/nsrt/array-intersect-key.php +++ b/tests/PHPStan/Analyser/nsrt/array-intersect-key.php @@ -19,7 +19,7 @@ public function nonEmpty(array $arr, array $arr2): void assertType('array', array_intersect_key($arr, $arr2)); assertType('array', array_intersect_key($arr2, $arr)); assertType('array{}', array_intersect_key($arr, [])); - assertType("array<'foo', string>", array_intersect_key($arr, ['foo' => 17])); + assertType('array{foo?: string}', array_intersect_key($arr, ['foo' => 17])); } @@ -34,7 +34,7 @@ public function normalArrays(array $arr, array $arr2, array $otherArrs): void assertType('array', array_intersect_key($arr, $arr2)); assertType('array', array_intersect_key($arr2, $arr)); assertType('array{}', array_intersect_key($arr, [])); - assertType("array<'foo', string>", array_intersect_key($arr, ['foo' => 17])); + assertType('array{foo?: string}', array_intersect_key($arr, ['foo' => 17])); /** @var array $otherArrs */ assertType('array', array_intersect_key($arr, $otherArrs)); @@ -43,12 +43,12 @@ public function normalArrays(array $arr, array $arr2, array $otherArrs): void /** @var array $otherArrs */ assertType('array', array_intersect_key($arr, $otherArrs)); /** @var array<17, int> $otherArrs */ - assertType('array<17, string>', array_intersect_key($arr, $otherArrs)); + assertType('array{17?: string}', array_intersect_key($arr, $otherArrs)); /** @var array $otherArrs */ - assertType('array<\'\', string>', array_intersect_key($arr, $otherArrs)); + assertType('array{\'\'?: string}', array_intersect_key($arr, $otherArrs)); if (array_key_exists(17, $arr2)) { - assertType('non-empty-array<17, string>&hasOffset(17)', array_intersect_key($arr2, [17 => 'bar'])); + assertType('array{17: string}', array_intersect_key($arr2, [17 => 'bar'])); /** @var array $otherArrs */ assertType('array', array_intersect_key($arr2, $otherArrs)); /** @var array $otherArrs */ @@ -56,7 +56,7 @@ public function normalArrays(array $arr, array $arr2, array $otherArrs): void } if (array_key_exists(17, $arr2) && $arr2[17] === 'foo') { - assertType("non-empty-array<17, string>&hasOffsetValue(17, 'foo')", array_intersect_key($arr2, [17 => 'bar'])); + assertType("array{17: 'foo'}", array_intersect_key($arr2, [17 => 'bar'])); /** @var array $otherArrs */ assertType('array', array_intersect_key($arr2, $otherArrs)); /** @var array $otherArrs */ @@ -79,7 +79,7 @@ public function arrayUnpacking(array $arrs, array $arrs2): void /** @param list $arr */ public function list(array $arr, array $otherArrs): void { - assertType('list', array_intersect_key($arr, ['foo', 'bar'])); + assertType('list{0?: string, 1?: string}', array_intersect_key($arr, ['foo', 'bar'])); /** @var array $otherArrs */ assertType('array, string>', array_intersect_key($arr, $otherArrs)); /** @var array $otherArrs */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-14747.php b/tests/PHPStan/Analyser/nsrt/bug-14747.php new file mode 100644 index 00000000000..eaef6fa4716 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14747.php @@ -0,0 +1,50 @@ + */ +function get_options(): array { + return ['foo' => 'bar']; +} + +function test(): void { + $options = get_options(); + + $o = array_intersect_key($options, ['a' => null, 'b' => null]); + assertType('array{a?: mixed, b?: mixed}', $o); +} + +/** + * @param array $arr + */ +function literalShape(array $arr): void { + assertType('array{foo?: string}', array_intersect_key($arr, ['foo' => 17])); + assertType('array{a?: string, b?: string}', array_intersect_key($arr, ['a' => 1, 'b' => 2])); +} + +/** + * @param array $arr + */ +function keysOutsideRange(array $arr): void { + // string keys cannot intersect int-keyed array + assertType('array{}', array_intersect_key($arr, ['foo' => 17, 'bar' => 18])); +} + +/** + * @param array $arr + */ +function unionOfShapes(array $arr, bool $b): void { + $other = $b ? ['a' => 1] : ['b' => 2, 'c' => 3]; + assertType('array{a?: string}|array{b?: string, c?: string}', array_intersect_key($arr, $other)); +} + +/** + * @param array $arr + * @param array{a: 1, ...} $unsealed + */ +function unsealedShape(array $arr, array $unsealed): void { + assertType('array{a?: string, ...}', array_intersect_key($arr, $unsealed)); +}