From bba81ce1a281faa7b17f0a36dc5ee47b5ef9fe13 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 25 May 2026 21:09:41 +0000 Subject: [PATCH 1/6] Narrow array key type when removing `HasOffsetType` via `ArrayType::tryRemove` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `HasOffsetType` and `HasOffsetValueType` handling to `ArrayType::tryRemove`, calling `unsetOffset()` to narrow the key type when an offset is known to not exist - This mirrors the existing `ConstantArrayType::tryRemove` behavior which already handles both `HasOffsetType` and `HasOffsetValueType` - Fixes `array_key_exists()` falsy branch not narrowing key types on general `ArrayType` (e.g. `array<0|string, mixed>` → `array`) - Works for both PHP >= 8.0 and PHP < 8.0 code paths, and propagates correctly through `IntersectionType` and `UnionType` via their `tryRemove` delegation --- src/Type/ArrayType.php | 5 ++ tests/PHPStan/Analyser/nsrt/bug-9461.php | 67 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9461.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index ebed9eedc2..4b8ebf7065 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -17,6 +17,7 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -875,6 +876,10 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } + if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + return $this->unsetOffset($typeToRemove->getOffsetType()); + } + return null; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9461.php b/tests/PHPStan/Analyser/nsrt/bug-9461.php new file mode 100644 index 0000000000..e30de10894 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9461.php @@ -0,0 +1,67 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug9461; + +use function array_key_exists; +use function PHPStan\Testing\assertType; + +function test(): void { + /** @var array<0|string, mixed> */ + $defaults = ['test', 'a' => 1]; + + assertType('array<0|string, mixed>', $defaults); + if (!array_key_exists(0, $defaults)) { + assertType('array', $defaults); + } else { + assertType('non-empty-array<0|string, mixed>&hasOffset(0)', $defaults); + } +} + +function testStringKey(): void { + /** @var array<'foo'|int, mixed> */ + $arr = []; + + if (!array_key_exists('foo', $arr)) { + assertType('array', $arr); + } else { + assertType("non-empty-array<'foo'|int, mixed>&hasOffset('foo')", $arr); + } +} + +function testGenericIntKey(): void { + /** @var array */ + $arr = []; + + if (!array_key_exists(0, $arr)) { + assertType('array|int<1, max>, mixed>', $arr); + } +} + +function testKeyExists(): void { + /** @var array<0|string, mixed> */ + $arr = []; + + if (!key_exists(0, $arr)) { + assertType('array', $arr); + } +} + +/** + * @param array<0|string, mixed>|string $label + * @return array + */ +function makeSeedFromLabel($label = []): array +{ + $defaults = is_array($label) ? $label : [$label]; + assertType('array<0|string, mixed>', $defaults); + + if (array_key_exists(0, $defaults)) { + $defaults['content'] = $defaults[0]; + unset($defaults[0]); + } + assertType('array', $defaults); + + return $defaults; +} From bf09eb9dd45d70807c3a018d43aaf999631f80af Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 21:33:21 +0000 Subject: [PATCH 2/6] Guard HasOffsetValueType removal with value type supertype check Only unset the offset when the HasOffsetValueType's value type is a supertype of the array's item type. Without this guard, removing HasOffsetValueType(0, string) from array<0|string, mixed> would incorrectly narrow to array even though offset 0 could still exist with a non-string value. Add TypeCombinatorTest cases covering both matching and non-matching value types. Co-Authored-By: Claude Opus 4.6 --- src/Type/ArrayType.php | 9 ++++++++- tests/PHPStan/Type/TypeCombinatorTest.php | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 4b8ebf7065..eddede8c82 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -876,10 +876,17 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } - if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove instanceof HasOffsetType) { return $this->unsetOffset($typeToRemove->getOffsetType()); } + if ($typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove->getValueType()->isSuperTypeOf($this->itemType)->yes()) { + return $this->unsetOffset($typeToRemove->getOffsetType()); + } + return null; + } + return null; } diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index f325c638da..e62d607fb6 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5575,6 +5575,24 @@ public static function dataRemove(): array IntersectionType::class, 'non-empty-array&oversized-array', ], + [ + new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new MixedType()), + new HasOffsetValueType(new ConstantIntegerType(0), new MixedType()), + ArrayType::class, + 'array', + ], + [ + new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new IntegerType()), + new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()), + ArrayType::class, + 'array', + ], + [ + new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new MixedType()), + new HasOffsetValueType(new ConstantIntegerType(0), new StringType()), + ArrayType::class, + 'array<0|string, mixed>', + ], ]; } From f24324044b9131f2eb781963f30b2bcb1e375c9f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 21:50:54 +0000 Subject: [PATCH 3/6] Guard HasOffsetValueType removal in ConstantArrayType::tryRemove with value type supertype check The same bug that was fixed in ArrayType::tryRemove also existed in ConstantArrayType::tryRemove: HasOffsetValueType was handled identically to HasOffsetType, unsetting the offset unconditionally without checking whether the value type actually covers the array's value at that offset. Co-Authored-By: Claude Opus 4.6 --- src/Type/Constant/ConstantArrayType.php | 17 ++++++++---- tests/PHPStan/Analyser/nsrt/bug-9461.php | 9 +++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 33 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 8850f7f45a..a8d0a875fc 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1835,18 +1835,25 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } - if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove instanceof HasOffsetType) { $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); - // When the source was definitely a list but the post-unset shape - // definitely isn't (e.g. unsetting a non-optional leading key - // creates a hole), no value of $this could have lacked the - // removed key — the subtraction yields the empty set. if ($this->isList->yes() && $unsetResult->isList()->no()) { return new NeverType(); } return $unsetResult; } + if ($typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove->getValueType()->isSuperTypeOf($this->getOffsetValueType($typeToRemove->getOffsetType()))->yes()) { + $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); + if ($this->isList->yes() && $unsetResult->isList()->no()) { + return new NeverType(); + } + return $unsetResult; + } + return null; + } + return null; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9461.php b/tests/PHPStan/Analyser/nsrt/bug-9461.php index e30de10894..1b5578f161 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9461.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9461.php @@ -48,6 +48,15 @@ function testKeyExists(): void { } } +function testConstantArrayValueTypeGuard(): void { + $arr = ['hello', 'a' => 'world']; + assertType("array{0: 'hello', a: 'world'}", $arr); + + if (!array_key_exists(0, $arr)) { + assertType('*NEVER*', $arr); + } +} + /** * @param array<0|string, mixed>|string $label * @return array diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index e62d607fb6..29a074fc1e 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5593,6 +5593,39 @@ public static function dataRemove(): array ArrayType::class, 'array<0|string, mixed>', ], + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('a')], + [new IntegerType(), new StringType()], + [1], + [0, 1], + ), + new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()), + ConstantArrayType::class, + 'array{a?: string}', + ], + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('a')], + [new IntegerType(), new StringType()], + [1], + [0, 1], + ), + new HasOffsetValueType(new ConstantIntegerType(0), new StringType()), + ConstantArrayType::class, + "array{0?: int, a?: string}", + ], + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('a')], + [new MixedType(), new StringType()], + [1], + [0, 1], + ), + new HasOffsetValueType(new ConstantIntegerType(0), new MixedType()), + ConstantArrayType::class, + 'array{a?: string}', + ], ]; } From d6ba96213c4211c9ab87f3db9760201bdfc360a4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 22:02:49 +0000 Subject: [PATCH 4/6] Merge HasOffsetType and HasOffsetValueType conditions in ConstantArrayType::tryRemove The two branches share identical unset+list-check logic. Merge them into a single condition with an early return for the HasOffsetValueType guard, reducing duplication. Co-Authored-By: Claude Opus 4.6 --- src/Type/Constant/ConstantArrayType.php | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index a8d0a875fc..dc40b61382 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1835,7 +1835,12 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } - if ($typeToRemove instanceof HasOffsetType) { + if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove instanceof HasOffsetValueType + && !$typeToRemove->getValueType()->isSuperTypeOf($this->getOffsetValueType($typeToRemove->getOffsetType()))->yes()) { + return null; + } + $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); if ($this->isList->yes() && $unsetResult->isList()->no()) { return new NeverType(); @@ -1843,17 +1848,6 @@ public function tryRemove(Type $typeToRemove): ?Type return $unsetResult; } - if ($typeToRemove instanceof HasOffsetValueType) { - if ($typeToRemove->getValueType()->isSuperTypeOf($this->getOffsetValueType($typeToRemove->getOffsetType()))->yes()) { - $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); - if ($this->isList->yes() && $unsetResult->isList()->no()) { - return new NeverType(); - } - return $unsetResult; - } - return null; - } - return null; } From 92a94f35bea1f93b46598a560254b2d9b216ff2a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 07:27:40 +0000 Subject: [PATCH 5/6] Add isset test to document that it does not narrow array key type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlike array_key_exists, isset also checks for null, so !isset($arr[0]) does not mean key 0 doesn't exist — the key type stays unchanged. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-9461.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-9461.php b/tests/PHPStan/Analyser/nsrt/bug-9461.php index 1b5578f161..02b9f55af1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9461.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9461.php @@ -57,6 +57,18 @@ function testConstantArrayValueTypeGuard(): void { } } +function testIssetDoesNotNarrowKeyType(): void { + /** @var array<0|string, mixed> */ + $arr = []; + + if (!isset($arr[0])) { + // isset also checks for null, so !isset doesn't mean key doesn't exist + assertType('array<0|string, mixed>', $arr); + } else { + assertType('non-empty-array<0|string, mixed>&hasOffsetValue(0, mixed~null)', $arr); + } +} + /** * @param array<0|string, mixed>|string $label * @return array From c661bcb7f665c5d3cc6f7fc87c25070096178a48 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 07:29:38 +0000 Subject: [PATCH 6/6] Add tests for non-empty-array key narrowing and isset non-narrowing - Test that non-empty-array (IntersectionType) preserves the non-empty constraint when narrowing key type via array_key_exists - Test that isset does not narrow key type in the falsy branch, since !isset can mean either the key is absent or the value is null Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-9461.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-9461.php b/tests/PHPStan/Analyser/nsrt/bug-9461.php index 02b9f55af1..2e115cc381 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9461.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9461.php @@ -57,6 +57,18 @@ function testConstantArrayValueTypeGuard(): void { } } +function testNonEmptyArray(): void { + /** @var non-empty-array<0|string, mixed> */ + $arr = ['test']; + + assertType('non-empty-array<0|string, mixed>', $arr); + if (!array_key_exists(0, $arr)) { + assertType('non-empty-array', $arr); + } else { + assertType('non-empty-array<0|string, mixed>&hasOffset(0)', $arr); + } +} + function testIssetDoesNotNarrowKeyType(): void { /** @var array<0|string, mixed> */ $arr = [];