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
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1680,7 +1680,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
identifier: phpstanApi.instanceofType
count: 16
count: 18
path: src/Type/TypeCombinator.php

-
Expand Down
75 changes: 67 additions & 8 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,15 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
return $builder->getArray();
}

public function unsetOffset(Type $offsetType): Type
/**
* Removes or marks as optional the key(s) matching the given offset type from this constant array.
*
* By default, the method assumes an actual `unset()` call was made, which actively modifies the
* array and weakens its list certainty to "maybe". However, in some contexts, such as the else
* branch of an array_key_exists() check, the key is statically known to be absent without any
* modification, so list certainty should be preserved as-is.
*/
public function unsetOffset(Type $offsetType, bool $preserveListCertainty = false): Type
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Kinda related to #5029 ; this is an idea I already had in mind. Without this fix tests are failing because we don't have the AccessoryIsList anymore (already merged into the constant array).

This method is used in two different context:

  • when unsetting the value
  • during something like
if (array_key_exist(...)) {

} else {
     // Here we're trying to remove the array key, and calling unsetOffset
}

For the second example, even if the key is "unset", shouldn't touch the list certainty since we didn't really modified the array. That's why tryRemove will call with 'true'.

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

Copy link
Contributor

@staabm staabm Feb 27, 2026

Choose a reason for hiding this comment

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

I think this needs a phpdoc for this parameter, because intuitively unsetting on a list will always destroy the list

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added

/**
	 * Removes or marks as optional the key(s) matching the given offset type from this constant array.
	 *
	 * By default, the method assumes an actual `unset()` call was made, which actively modifies the
	 * array and weakens its list certainty to "maybe". However, in some contexts, such as the else
	 * branch of an array_key_exists() check, the key is statically known to be absent without any
	 * modification, so list certainty should be preserved as-is.
	 */

{
$offsetType = $offsetType->toArrayKey();
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
Expand Down Expand Up @@ -749,6 +757,11 @@ public function unsetOffset(Type $offsetType): Type
$this->isList,
in_array($i, $this->optionalKeys, true),
);
if (!$preserveListCertainty) {
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
} elseif ($this->isList->yes() && $newIsList->no()) {
return new NeverType();
}

return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
}
Expand Down Expand Up @@ -791,6 +804,11 @@ public function unsetOffset(Type $offsetType): Type
$this->isList,
count($optionalKeys) === count($this->optionalKeys),
);
if (!$preserveListCertainty) {
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
} elseif ($this->isList->yes() && $newIsList->no()) {
return new NeverType();
}

return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
}
Expand All @@ -816,6 +834,11 @@ public function unsetOffset(Type $offsetType): Type
$this->isList,
count($optionalKeys) === count($this->optionalKeys),
);
if (!$preserveListCertainty) {
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
} elseif ($this->isList->yes() && $newIsList->no()) {
return new NeverType();
}

return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
}
Expand Down Expand Up @@ -851,7 +874,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK
}
}

return TrinaryLogic::createMaybe();
return $arrayIsList;
}

public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
Expand Down Expand Up @@ -1531,7 +1554,9 @@ private function getKeysOrValuesArray(array $types): self

public function describe(VerbosityLevel $level): string
{
$describeValue = function (bool $truncate) use ($level): string {
$arrayName = $this->shouldBeDescribedAsAList() ? 'list' : 'array';

$describeValue = function (bool $truncate) use ($level, $arrayName): string {
$items = [];
$values = [];
$exportValuesOnly = true;
Expand Down Expand Up @@ -1570,18 +1595,36 @@ public function describe(VerbosityLevel $level): string
}

return sprintf(
'array{%s%s}',
'%s{%s%s}',
$arrayName,
implode(', ', $exportValuesOnly ? $values : $items),
$append,
);
};
return $level->handle(
fn (): string => $this->isIterableAtLeastOnce()->no() ? 'array' : sprintf('array<%s, %s>', $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
fn (): string => $this->isIterableAtLeastOnce()->no() ? $arrayName : sprintf('%s<%s, %s>', $arrayName, $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
static fn (): string => $describeValue(true),
static fn (): string => $describeValue(false),
);
}

private function shouldBeDescribedAsAList(): bool
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since ConstantArray&AccessoryArrayIsList is simplified into ConstantArray, I always get a description like

array{...}

This is annoying when having too much optional keys.

I tried to find the formula to change as few description as possible:

  • array like array{0: string, 1: string} without optional keys are untouched because they are always a list
  • same for array like array{0: string, 1: string, 2?: string} where the optional keys is the last one
  • list{0: string, 1?: string, 2: string} should stay a list (in the next PR I'll make the offset 1 required)
  • list{0: string, 1?: string, 2?: string} with 2+ optional keys should stay a list.

So we cannot only rely on IntersectionType to add the list keyWord.

{
if (!$this->isList->yes()) {
return false;
}

if (count($this->optionalKeys) === 0) {
return false;
}

if (count($this->optionalKeys) > 1) {
return true;
}

return $this->optionalKeys[0] !== count($this->keyTypes) - 1;
}

public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
{
if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) {
Expand Down Expand Up @@ -1643,11 +1686,11 @@ public function tryRemove(Type $typeToRemove): ?Type
}

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

if ($typeToRemove instanceof HasOffsetValueType) {
return $this->unsetOffset($typeToRemove->getOffsetType());
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
}

return null;
Expand Down Expand Up @@ -1823,6 +1866,19 @@ public function makeOffsetRequired(Type $offsetType): self
return $this;
}

public function makeList(): Type
{
if ($this->isList->yes()) {
return $this;
}

if ($this->isList->no()) {
return new NeverType();
}

return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes());
}

public function toPhpDocNode(): TypeNode
{
$items = [];
Expand Down Expand Up @@ -1863,7 +1919,10 @@ public function toPhpDocNode(): TypeNode
);
}

return ArrayShapeNode::createSealed($exportValuesOnly ? $values : $items);
return ArrayShapeNode::createSealed(
$exportValuesOnly ? $values : $items,
$this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
);
}

public static function isValidIdentifier(string $value): bool
Expand Down
4 changes: 3 additions & 1 deletion src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use function is_int;
use function ksort;
use function sprintf;
use function str_starts_with;
use function strcasecmp;
use function strlen;
use function substr;
Expand Down Expand Up @@ -448,7 +449,8 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
continue;
} elseif ($type instanceof ConstantArrayType) {
$description = $type->describe($level);
$descriptionWithoutKind = substr($description, strlen('array'));
$kind = str_starts_with($description, 'list') ? 'list' : 'array';
$descriptionWithoutKind = substr($description, strlen($kind));
Comment on lines +452 to +453
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now constant array is not always a array{...} and strlen of list and array are different so I need to check it.

$begin = $isList ? 'list' : 'array';
if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) {
$begin = 'non-empty-' . $begin;
Expand Down
14 changes: 14 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,20 @@ public static function intersect(Type ...$types): Type
continue 2;
}

if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof AccessoryArrayListType) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The main idea of this PR.

Before, we had sometime a ConstantArray(isList = true) and sometimes ConstantArray(isList=maybe)&AccessoryArrayListType ; now I merged them to always have ConstantArray(isList = true).

Copy link
Contributor

@staabm staabm Feb 27, 2026

Choose a reason for hiding this comment

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

I wonder why these 2 types of "isList" exist in the first place. could this always be using AccessoryArrayListType?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ConstantArray need to implement isList, if you return Maybe for this method, you'll end up with too many test failure ; having to intersect with AccessoryArrayListType every time, even when doing a basic $a = [].

Also, there is some optimisation in the ConstantArray possible when we know it's a list. It would be harder to do such thing in the intersection type. And that's the whole purpose of this PR ; currently some optimisation are ignored because the array doesn't know it's a list. And some other optimisation are possible.

$types[$i] = $types[$i]->makeList();
array_splice($types, $j--, 1);
$typesCount--;
continue;
}

if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof AccessoryArrayListType) {
$types[$j] = $types[$j]->makeList();
array_splice($types, $i--, 1);
$typesCount--;
continue 2;
}

if (
$types[$i] instanceof ConstantArrayType
&& count($types[$i]->getKeyTypes()) === 1
Expand Down
6 changes: 3 additions & 3 deletions tests/PHPStan/Analyser/nsrt/array-chunk.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ public function constantArraysWithOptionalKeys(array $arr): void
*/
public function chunkUnionTypeLength(array $arr, $positiveRange, $positiveUnion) {
/** @var array{a: 0, b?: 1, c: 2} $arr */
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange));
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is some bonus of this PR.

Before a ConstantArray(isList = true) was described as array in every occasion.

Now I describe it as list in some conditions, which gives a meaningful type.

As an example here array_is_list would have a return true for array{0: 0, 1?: 1|2, 2?: 2} but this would not have been clear for the user why.
See https://phpstan.org/r/a82c93cb-e834-4fd9-8c01-6bd06446ec04

assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveRange, true));
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion));
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion));
assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveUnion, true));
}

Expand All @@ -70,7 +70,7 @@ public function lengthIntRanges(array $arr, int $positiveInt, int $bigger50) {
*/
function testLimits(array $arr, int $oneToFour, int $tooBig) {
/** @var array{a: 0, b?: 1, c: 2, d: 3} $arr */
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: array{0?: 2|3, 1?: 3}}|array{array{0}, array{0?: 1|2, 1?: 2}, array{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour));
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour));
assertType('non-empty-list<non-empty-list<0|1|2|3>>', array_chunk($arr, $tooBig));
}

Expand Down
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/array-column.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ public function testConstantArray12(array $array): void
/** @param array{0?: array{column: 'foo1', key: 'bar1'}, 1?: array{column: 'foo2', key: 'bar2'}} $array */
public function testConstantArray13(array $array): void
{
assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column'));
assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null));
assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column'));
assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null));
assertType("array{bar1?: 'foo1', bar2?: 'foo2'}", array_column($array, 'column', 'key'));
}

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array-reverse.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function constantArrays(array $a, array $b, array $c): void
assertType('array{\'bar\', \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b));
assertType('array{19: \'bar\', 17: \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b, true));

assertType("array{0: 'A'|'B'|'C', 1?: 'A'|'B', 2?: 'A'}", array_reverse($c));
assertType("list{0: 'A'|'B'|'C', 1?: 'A'|'B', 2?: 'A'}", array_reverse($c));
assertType("array{2?: 'C', 1?: 'B', 0: 'A'}", array_reverse($c, true));
}

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array_keys.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ public function constantArrayType(): void
[1 => 'a', 2 => 'b', 3 => 'c'],
static fn ($value) => mt_rand(0, 1) === 0,
);
assertType("array{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers));
assertType("list{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers));
}
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array_values.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function constantArrayType(): void
[1 => 'a', 2 => 'b', 3 => 'c'],
static fn ($value) => mt_rand(0, 1) === 0,
);
assertType("array{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers));
assertType("list{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers));
}

/**
Expand Down
23 changes: 22 additions & 1 deletion tests/PHPStan/Analyser/nsrt/bug-14177.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public function testList(array $b): void
if (array_key_exists(3, $b)) {
assertType('list{0: string, 1: string, 2?: string, 3: string}', $b);
} else {
assertType('list{0: string, 1: string, 2?: string}', $b);
assertType('array{0: string, 1: string, 2?: string}', $b);
}
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
}
Expand Down Expand Up @@ -200,4 +200,25 @@ public function testUnsetInt(array $a, array $b, array $c, int $int): void
assertType('bool', array_is_list($a));
assertType('false', array_is_list($b));
}

/**
* @param list{0?: string, 1?: string, 2?: string} $l
*/
public function testFoo($l): void
{
if (array_key_exists(2, $l, true)) {
assertType('true', array_is_list($l));
assertType('list{0?: string, 1?: string, 2: string}', $l);
if (array_key_exists(1, $l, true)) {
assertType('true', array_is_list($l));
assertType('list{0?: string, 1: string, 2: string}', $l);
} else {
assertType('true', array_is_list($l));
assertType('*NEVER*', $l);
}
} else {
assertType('true', array_is_list($l));
assertType('list{0?: string, 1?: string}', $l);
}
}
}
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/bug-4700.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function(array $array, int $count): void {
if (isset($array['e'])) $a[] = $array['e'];
if (count($a) >= $count) {
assertType('int<1, 5>', count($a));
assertType('array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
} else {
assertType('0', count($a));
assertType('array{}', $a);
Expand All @@ -44,6 +44,6 @@ function(array $array, int $count): void {
assertType('list{0: mixed~null, 1: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
} else {
assertType('int<0, 5>', count($a)); // Could be int<0, 1>
assertType('array{}|array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); // Could be array{}|array{0: mixed~null}
assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); // Could be array{}|array{0: mixed~null}
}
};
6 changes: 3 additions & 3 deletions tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ public function doFoo()
if (rand(0, 1)) {
$a[] = 3;
}
assertType('array{0: 1, 1?: 2|3, 2?: 3}', $a);
assertType('list{0: 1, 1?: 2|3, 2?: 3}', $a);
if (rand(0, 1)) {
$a[] = 4;
}
assertType('array{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a);
assertType('list{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a);
if (rand(0, 1)) {
$a[] = 5;
}
assertType('array{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a);
assertType('list{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a);
}

public function doBar()
Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Analyser/nsrt/preg_match_shapes.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ function doFoo(string $row): void
assertType("array{0: non-falsy-string, 1: non-falsy-string, 2?: 'b'}", $matches);
}
if (preg_match('~^(a(b)?)?$~', $row, $matches) === 1) {
assertType("array{0: string, 1?: non-falsy-string, 2?: 'b'}", $matches);
assertType("list{0: string, 1?: non-falsy-string, 2?: 'b'}", $matches);
}
}

Expand Down Expand Up @@ -286,7 +286,7 @@ function (string $size): void {
if (preg_match('~^a\.b(c(\d+)?)?d~', $size, $matches) !== 1) {
throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size));
}
assertType('array{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches);
assertType('list{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches);
};

function (string $size): void {
Expand Down Expand Up @@ -346,11 +346,11 @@ function bug11277b(string $value): void
// https://3v4l.org/09qdT
function bug11291(string $s): void {
if (preg_match('/(?|(a)|(b)(c)|(d)(e)(f))/', $s, $matches)) {
assertType('array{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
assertType('list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
} else {
assertType('array{}', $matches);
}
assertType('array{}|array{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
assertType('array{}|list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
}

function bug11323a(string $s): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,12 @@ public function testBug6209(): void
$this->analyse([__DIR__ . '/data/bug-6209.php'], []);
}

public function testBug11602(): void
{
$this->reportPossiblyNonexistentConstantArrayOffset = true;
$this->analyse([__DIR__ . '/data/bug-11602.php'], []);
}

public function testBug11276(): void
{
$this->reportPossiblyNonexistentConstantArrayOffset = true;
Expand Down
Loading
Loading