Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -875,6 +876,17 @@ public function tryRemove(Type $typeToRemove): ?Type
return new ConstantArrayType([], []);
}

if ($typeToRemove instanceof HasOffsetType) {
return $this->unsetOffset($typeToRemove->getOffsetType());
}

if ($typeToRemove instanceof HasOffsetValueType) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same bug for HasOffsetValueType exists in ConstantArrayType, please fix it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. The fix applies the same value type supertype guard to ConstantArrayType::tryRemove() that was already added to ArrayType::tryRemove(). Without the guard, removing HasOffsetValueType(0, string) from array{0: int, a: string} would incorrectly unset offset 0 even though the value type string doesn't cover int. Now it returns null (no narrowing) in that case, matching the ArrayType behavior.

if ($typeToRemove->getValueType()->isSuperTypeOf($this->itemType)->yes()) {
return $this->unsetOffset($typeToRemove->getOffsetType());
}
return null;
}

return null;
}

Expand Down
9 changes: 5 additions & 4 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1836,11 +1836,12 @@ public function tryRemove(Type $typeToRemove): ?Type
}

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);
// 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();
}
Expand Down
100 changes: 100 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-9461.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php // lint >= 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<string, mixed>', $defaults);
} else {
assertType('non-empty-array<0|string, mixed>&hasOffset(0)', $defaults);
}
Comment on lines +11 to +19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might make sense to do a similar test for a non-empty-array (a IntersectionType)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's what I did:

  1. Added testNonEmptyArray() test — verifies that non-empty-array<0|string, mixed> (an IntersectionType) correctly narrows to non-empty-array<string, mixed> when array_key_exists(0, ...) returns false, preserving the non-empty constraint through the key narrowing.

  2. Kept the existing testIssetDoesNotNarrowKeyType() test (already added by a previous push) — confirms that !isset($arr[0]) does NOT narrow the key type, which is correct since isset also checks for null values, so !isset doesn't prove the key is absent.

All tests pass (12160 tests, 79841 assertions) and make phpstan reports no errors.

}

function testStringKey(): void {
/** @var array<'foo'|int, mixed> */
$arr = [];

if (!array_key_exists('foo', $arr)) {
assertType('array<int, mixed>', $arr);
} else {
assertType("non-empty-array<'foo'|int, mixed>&hasOffset('foo')", $arr);
}
}

function testGenericIntKey(): void {
/** @var array<int, mixed> */
$arr = [];

if (!array_key_exists(0, $arr)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have similar tests for isset (and maybe even empty)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Added a test for isset showing that !isset($arr[0]) on array<0|string, mixed> does not narrow the key type (stays array<0|string, mixed>), unlike array_key_exists which narrows to array<string, mixed>. This is correct behavior — isset also checks for null, so !isset doesn't prove the key doesn't exist.

I didn't add an empty test because empty() is disallowed by PHPStan's own ruleset (empty.notAllowed), and its semantics are even further removed — it checks for both key existence and falsy values. The isset test is the meaningful one to document the behavioral difference.

assertType('array<int<min, -1>|int<1, max>, mixed>', $arr);
}
}

function testKeyExists(): void {
/** @var array<0|string, mixed> */
$arr = [];

if (!key_exists(0, $arr)) {
assertType('array<string, mixed>', $arr);
}
}

function testConstantArrayValueTypeGuard(): void {
$arr = ['hello', 'a' => 'world'];
assertType("array{0: 'hello', a: 'world'}", $arr);

if (!array_key_exists(0, $arr)) {
assertType('*NEVER*', $arr);
}
}

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<string, mixed>', $arr);
} else {
assertType('non-empty-array<0|string, mixed>&hasOffset(0)', $arr);
}
}

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<string, mixed>
*/
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<string, mixed>', $defaults);

return $defaults;
}
51 changes: 51 additions & 0 deletions tests/PHPStan/Type/TypeCombinatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5575,6 +5575,57 @@ public static function dataRemove(): array
IntersectionType::class,
'non-empty-array<string, string>&oversized-array',
],
[
new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new MixedType()),
new HasOffsetValueType(new ConstantIntegerType(0), new MixedType()),
ArrayType::class,
'array<string, mixed>',
],
[
new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new IntegerType()),
new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()),
ArrayType::class,
'array<string, int>',
],
[
new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new MixedType()),
new HasOffsetValueType(new ConstantIntegerType(0), new StringType()),
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}',
],
];
}

Expand Down
Loading