Skip to content
Merged
16 changes: 12 additions & 4 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -1515,10 +1515,14 @@ public static function intersect(Type ...$types): Type
$newArray = ConstantArrayTypeBuilder::createEmpty();
$valueTypes = $types[$i]->getValueTypes();
foreach ($types[$i]->getKeyTypes() as $k => $keyType) {
$hasOffset = $types[$j]->hasOffsetValueType($keyType);
if ($hasOffset->no()) {
continue;
}
$newArray->setOffsetValueType(
self::intersect($keyType, $types[$j]->getIterableKeyType()),
self::intersect($valueTypes[$k], $types[$j]->getIterableValueType()),
$types[$i]->isOptionalKey($k) && !$types[$j]->hasOffsetValueType($keyType)->yes(),
self::intersect($valueTypes[$k], $types[$j]->getOffsetValueType($keyType)),
$types[$i]->isOptionalKey($k) && !$hasOffset->yes(),
);
}
$types[$i] = $newArray->getArray();
Expand All @@ -1531,10 +1535,14 @@ public static function intersect(Type ...$types): Type
$newArray = ConstantArrayTypeBuilder::createEmpty();
$valueTypes = $types[$j]->getValueTypes();
foreach ($types[$j]->getKeyTypes() as $k => $keyType) {
$hasOffset = $types[$i]->hasOffsetValueType($keyType);
if ($hasOffset->no()) {
continue;
}
$newArray->setOffsetValueType(
self::intersect($keyType, $types[$i]->getIterableKeyType()),
self::intersect($valueTypes[$k], $types[$i]->getIterableValueType()),
$types[$j]->isOptionalKey($k) && !$types[$i]->hasOffsetValueType($keyType)->yes(),
self::intersect($valueTypes[$k], $types[$i]->getOffsetValueType($keyType)),
$types[$j]->isOptionalKey($k) && !$hasOffset->yes(),
);
}
$types[$j] = $newArray->getArray();
Expand Down
79 changes: 79 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11234.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php // lint >= 8.0

namespace Bug11234;

use function PHPStan\Testing\assertType;

class Payload {}

/** @param array{0|1|2|3, int|Payload|string|null}&array{int, Payload} $x */
Copy link
Copy Markdown
Contributor

@staabm staabm Apr 15, 2026

Choose a reason for hiding this comment

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

Should we test with a int-range as array-key?

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.

Done. I added two test cases to address staabm's review comments:

  1. testIntersectIntRangeValue — Tests with int<0, 3> (integer range) as a value type at position 0, verifying that array{int<0, 3>, int|Payload|string|null} & array{int, Payload} correctly yields array{int<0, 3>, Payload}.

  2. testIntersectNonEmptyStringValue — Tests with non-empty-string (an intersection/accessory type) as a value type at position 0, verifying that array{non-empty-string, int|Payload|string|null} & array{string, Payload} correctly yields array{non-empty-string, Payload}.

All 11859 tests pass (79369 assertions) and make phpstan reports no errors.

function testIntersectConstantUnionWithInt(mixed $x): void
{
assertType('array{0|1|2|3, Bug11234\Payload}', $x);
}

/** @param array{int, Payload}&array{0|1|2|3, int|Payload|string|null} $x */
function testIntersectConstantUnionWithIntReverse(mixed $x): void
{
assertType('array{0|1|2|3, Bug11234\Payload}', $x);
}

/** @param array{0|1|2|3, int|Payload|string|null}&array{0|1|2|3, Payload} $x */
function testIntersectBothConstantUnion(mixed $x): void
{
assertType('array{0|1|2|3, Bug11234\Payload}', $x);
}

/** @param array{int, int|Payload|string|null}&array{int, Payload} $y */
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.

I think we should also test with intersection types, like non-empty-string

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.

Done. Added three new test cases addressing staabm's review:

  1. testIntersectIntRangeValue (line 57): Tests with int<0, 3> as a value type — verifies the intersection correctly narrows int<0, 3> against int without leaking.

  2. testIntersectNonEmptyStringInUnion (line 69): Tests non-empty-string as part of a union value (non-empty-string|int|null) intersected with string — verifies the intersection type non-empty-string is preserved.

  3. testIntersectWithNonEmptyStringOtherSide (line 75): Tests string|null intersected with non-empty-string from the other array — verifies narrowing from string to non-empty-string works correctly.

There was also a prior remote commit that added a testIntersectNonEmptyStringValue test (non-empty-string as a value at position 0). All test cases are now merged together. All 11859 tests pass and make phpstan reports no errors.

function testIntersectPlainInt(mixed $y): void
{
assertType('array{int, Bug11234\Payload}', $y);
}

/** @param array{0|1, string|int, Payload|null}&array{int, string, Payload} $z */
function testIntersectThreePositions(mixed $z): void
{
assertType('array{0|1, string, Bug11234\Payload}', $z);
}

/** @param array{'a'|'b', int|Payload|string|null}&array{string, Payload} $w */
function testIntersectStringConstantUnion(mixed $w): void
{
assertType("array{'a'|'b', Bug11234\Payload}", $w);
}

/** @param array{0|1, int|string}&array{int, int, extra?: bool} $v */
function testIntersectOptionalKey(mixed $v): void
{
assertType('array{0|1, int}', $v);
}

/** @param array{true|false, int|string}&array{bool, string} $u */
function testIntersectBoolConstantUnion(mixed $u): void
{
assertType('array{bool, string}', $u);
}

/** @param array{int<0, 3>, int|Payload|string|null}&array{int, Payload} $x */
function testIntersectIntRangeValue(mixed $x): void
{
assertType('array{int<0, 3>, Bug11234\Payload}', $x);
}

/** @param array{non-empty-string, int|Payload|string|null}&array{string, Payload} $x */
function testIntersectNonEmptyStringValue(mixed $x): void
{
assertType('array{non-empty-string, Bug11234\Payload}', $x);
}

/** @param array{0|1|2|3, non-empty-string|int|null}&array{int, string} $x */
function testIntersectNonEmptyStringInUnion(mixed $x): void
{
assertType('array{0|1|2|3, non-empty-string}', $x);
}

/** @param array{0|1|2|3, string|null}&array{int, non-empty-string} $x */
function testIntersectWithNonEmptyStringOtherSide(mixed $x): void
{
assertType('array{0|1|2|3, non-empty-string}', $x);
}
18 changes: 18 additions & 0 deletions tests/PHPStan/Type/TypeCombinatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4933,6 +4933,24 @@ public static function dataIntersect(): iterable
TemplateIntersectionType::class,
'T of Countable&Iterator (function a(), parameter)',
];

yield [
[
new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1), new ConstantIntegerType(2), new ConstantIntegerType(3)]),
new UnionType([new IntegerType(), new ObjectType('stdClass'), new StringType(), new NullType()]),
],
),
new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[new IntegerType(), new ObjectType('stdClass')],
),
],
ConstantArrayType::class,
'array{0|1|2|3, stdClass}',
];
}

/**
Expand Down
Loading