Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Resolve and print the unsealed part of a template-keyed array shape
`toPhpDocNode()` passed the unsealed key and value to
ArrayShapeUnsealedTypeNode in the wrong order, so an unresolved shape
described itself as `array{TKey: int, ...<mixed, int|string>}`.

The unsealed key type is now kept as null while it is not written down,
so that `array{TKey: int, ...}` and `array{TKey: int, ...<bool>}` print
back the way they were written, and the implicit key type is derived from
the shape kind where the shape gets built.

Template types in the unsealed key are resolved to their bounds the same
way item keys are, instead of ending up inside the resulting array type.

Two pieces of logic turned out to have no effect and are gone: `describe()`
never sees a resolvable shape (create() resolves those right away), and
`disableClosureDegradation()` cannot do anything on a builder that already
has array degradation disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Loading branch information
phpstan-bot and claude committed Aug 31, 2026
commit 342848f26a8d8231fb2bbdb9e9d7b2f8c54e6b49
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
identifier: phpstanApi.instanceofType
count: 3
count: 1
path: src/PhpDoc/TypeNodeResolver.php

-
Expand Down
28 changes: 7 additions & 21 deletions src/PhpDoc/TypeNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@
use PHPStan\Type\IntegerType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\IterableType;
use PHPStan\Type\LateResolvableArrayShapeType;
use PHPStan\Type\KeyOfType;
use PHPStan\Type\LateResolvableArrayShapeType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NewObjectType;
use PHPStan\Type\NonAcceptingNeverType;
Expand Down Expand Up @@ -1141,35 +1141,21 @@ private function resolveCallableReturnTypeAssertions(CallableTypeNode $typeNode,
private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $nameScope): Type
{
$items = [];
$hasCallableItem = false;
foreach ($typeNode->items as $itemNode) {
if ($itemNode->valueType instanceof CallableTypeNode) {
$hasCallableItem = true;
}

$items[] = [
$this->resolveArrayShapeOffsetType($itemNode, $nameScope),
$this->resolve($itemNode->valueType, $nameScope),
$itemNode->optional,
];
}

$isList = in_array($typeNode->kind, [
ArrayShapeNode::KIND_LIST,
ArrayShapeNode::KIND_NON_EMPTY_LIST,
], true);

$unsealed = null;
if (!$typeNode->sealed) {
if ($typeNode->unsealedType === null || $typeNode->unsealedType->keyType === null) {
if ($isList) {
$unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0);
} else {
$unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
}
} else {
$unsealedKeyType = $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope));
}
// A key type that is not written down is derived from the shape kind
// when the shape gets built, so that it can be printed back as `...`.
$unsealedKeyType = $typeNode->unsealedType === null || $typeNode->unsealedType->keyType === null
? null
: $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope));

$unsealedValueType = $typeNode->unsealedType === null
? new MixedType()
Expand All @@ -1178,7 +1164,7 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name
$unsealed = [$unsealedKeyType, $unsealedValueType];
}

return LateResolvableArrayShapeType::create($items, $unsealed, $typeNode->kind, $hasCallableItem);
return LateResolvableArrayShapeType::create($items, $unsealed, $typeNode->kind);
}

private function resolveArrayShapeOffsetType(ArrayShapeItemNode $itemNode, NameScope $nameScope): ?Type
Expand Down
153 changes: 101 additions & 52 deletions src/Type/LateResolvableArrayShapeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Printer\Printer;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Traits\LateResolvableTypeTrait;
Expand All @@ -40,14 +39,13 @@ final class LateResolvableArrayShapeType implements CompoundType, LateResolvable

/**
* @param list<array{Type|null, Type, bool}> $items keyType (null for auto-index), valueType, optional
* @param array{Type, Type}|null $unsealed
* @param array{Type|null, Type}|null $unsealed keyType (null when not written down), valueType
* @param ArrayShapeNode::KIND_* $kind
*/
private function __construct(
private array $items,
private ?array $unsealed,
private string $kind,
private bool $hasCallableItem,
)
{
}
Expand All @@ -57,12 +55,12 @@ private function __construct(
* as some of its keys still contain template types.
*
* @param list<array{Type|null, Type, bool}> $items keyType (null for auto-index), valueType, optional
* @param array{Type, Type}|null $unsealed
* @param array{Type|null, Type}|null $unsealed keyType (null when not written down), valueType
* @param ArrayShapeNode::KIND_* $kind
*/
public static function create(array $items, ?array $unsealed, string $kind, bool $hasCallableItem): Type
public static function create(array $items, ?array $unsealed, string $kind): Type
{
$self = new self($items, $unsealed, $kind, $hasCallableItem);
$self = new self($items, $unsealed, $kind);
if ($self->isResolvable()) {
return $self->resolve();
}
Expand All @@ -82,7 +80,10 @@ public function getReferencedClasses(): array

if ($this->unsealed !== null) {
[$unsealedKeyType, $unsealedValueType] = $this->unsealed;
$classes = array_merge($classes, $unsealedKeyType->getReferencedClasses(), $unsealedValueType->getReferencedClasses());
if ($unsealedKeyType !== null) {
$classes = array_merge($classes, $unsealedKeyType->getReferencedClasses());
}
$classes = array_merge($classes, $unsealedValueType->getReferencedClasses());
}

return $classes;
Expand Down Expand Up @@ -110,11 +111,10 @@ public function getReferencedTemplateTypes(TemplateTypeVariance $positionVarianc

if ($this->unsealed !== null) {
[$unsealedKeyType, $unsealedValueType] = $this->unsealed;
$references = array_merge(
$references,
$unsealedKeyType->getReferencedTemplateTypes($positionVariance),
$unsealedValueType->getReferencedTemplateTypes($positionVariance),
);
if ($unsealedKeyType !== null) {
$references = array_merge($references, $unsealedKeyType->getReferencedTemplateTypes($positionVariance));
}
$references = array_merge($references, $unsealedValueType->getReferencedTemplateTypes($positionVariance));
}

return $references;
Expand Down Expand Up @@ -151,19 +151,23 @@ public function equals(Type $type): bool
}

if ($this->unsealed !== null && $type->unsealed !== null) {
return $this->unsealed[0]->equals($type->unsealed[0])
&& $this->unsealed[1]->equals($type->unsealed[1]);
if (($this->unsealed[0] === null) !== ($type->unsealed[0] === null)) {
return false;
}

if ($this->unsealed[0] !== null && $type->unsealed[0] !== null && !$this->unsealed[0]->equals($type->unsealed[0])) {
return false;
}

return $this->unsealed[1]->equals($type->unsealed[1]);
}

return true;
}

public function describe(VerbosityLevel $level): string
{
if ($this->isResolvable()) {
return $this->resolve()->describe($level);
}

// a shape that can be resolved never stays late-resolvable, see create()
return (new Printer())->print($this->toPhpDocNode());
}

Expand All @@ -179,7 +183,7 @@ public function isResolvable(): bool
}
}

if ($this->unsealed !== null && TypeUtils::containsTemplateType($this->unsealed[0])) {
if ($this->unsealed !== null && $this->unsealed[0] !== null && TypeUtils::containsTemplateType($this->unsealed[0])) {
return false;
}

Expand All @@ -190,36 +194,17 @@ protected function getResult(): Type
{
$builder = ConstantArrayTypeBuilder::createEmpty();
$builder->disableArrayDegradation();
if ($this->hasCallableItem) {
$builder->disableClosureDegradation();
}

$explicitKeyValues = [];
foreach ($this->items as [$keyType, $valueType, $optional]) {
if ($keyType !== null) {
$keyType = $this->toArrayKeyType($keyType);
if ($keyType instanceof ErrorType) {
return $keyType;
}

if (TypeUtils::containsTemplateType($keyType)) {
// The template type is never going to be resolved here, so
// the shape degrades into a general array. Its key has to be
// a valid array key though, which a template type bound by
// e.g. mixed is not.
$keyType = TemplateTypeHelper::resolveToBounds($keyType);
}

$arrayKeyType = $keyType->toArrayKey();
if ($arrayKeyType instanceof ErrorType) {
return new ErrorType(sprintf(
'Type %s cannot be used as an array shape key.',
$keyType->describe(VerbosityLevel::typeOnly()),
));
}

$keyType = $arrayKeyType;
if ($keyType instanceof ConstantIntegerType || $keyType instanceof ConstantStringType) {
$explicitKeyValues[] = $keyType->getValue();
foreach ($keyType->getConstantScalarValues() as $keyValue) {
$explicitKeyValues[] = $keyValue;
}
}

Expand All @@ -232,16 +217,15 @@ protected function getResult(): Type
], true);

if ($this->unsealed !== null) {
[$unsealedKeyType, $unsealedValueType] = $this->unsealed;
$unsealedValueType = $this->unsealed[1];
$unsealedKeyType = $this->toArrayKeyType($this->getUnsealedKeyType());
$unsealedKeyFiniteTypes = $unsealedKeyType->getFiniteTypes();
if (count($unsealedKeyFiniteTypes) > 0) {
foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) {
// Explicit keys own their slot — the unsealed extras
// describe entries at keys NOT in the explicit set.
if (
($unsealedKeyFiniteType instanceof ConstantIntegerType || $unsealedKeyFiniteType instanceof ConstantStringType)
&& in_array($unsealedKeyFiniteType->getValue(), $explicitKeyValues, true)
) {
$finiteKeyValues = $unsealedKeyFiniteType->getConstantScalarValues();
if (count($finiteKeyValues) === 1 && in_array($finiteKeyValues[0], $explicitKeyValues, true)) {
continue;
}
$builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true);
Expand Down Expand Up @@ -272,6 +256,59 @@ protected function getResult(): Type
return $arrayType;
}

/**
* Turns a type written down as an array shape key into the type of the key
* it actually creates, or an ErrorType when it cannot be an array key.
*
* Template types are resolved to their bounds: at this point they are never
* going to be substituted anymore, so the shape degrades into a general
* array keyed by the bound.
*/
private function toArrayKeyType(Type $keyType): Type
{
if ($keyType instanceof ErrorType) {
return $keyType;
}

if (TypeUtils::containsTemplateType($keyType)) {
$keyType = TemplateTypeHelper::resolveToBounds($keyType);
}

$arrayKeyType = $keyType->toArrayKey();
if ($arrayKeyType instanceof ErrorType) {
return new ErrorType(sprintf(
'Type %s cannot be used as an array shape key.',
$keyType->describe(VerbosityLevel::typeOnly()),
));
}

return $arrayKeyType;
}

/**
* The unsealed key type as written down, or the implicit one when the
* shape was written as `array{...}` or `array{...<ValueType>}`.
*/
private function getUnsealedKeyType(): Type
{
if ($this->unsealed === null) {
throw new ShouldNotHappenException();
}

if ($this->unsealed[0] !== null) {
return $this->unsealed[0];
}

if (in_array($this->kind, [
ArrayShapeNode::KIND_LIST,
ArrayShapeNode::KIND_NON_EMPTY_LIST,
], true)) {
return IntegerRangeType::createAllGreaterThanOrEqualTo(0);
}

return (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
}

/**
* @param callable(Type): Type $cb
*/
Expand All @@ -292,7 +329,7 @@ public function traverse(callable $cb): Type
$unsealed = $this->unsealed;
if ($unsealed !== null) {
[$unsealedKeyType, $unsealedValueType] = $unsealed;
$newUnsealedKeyType = $cb($unsealedKeyType);
$newUnsealedKeyType = $unsealedKeyType !== null ? $cb($unsealedKeyType) : null;
$newUnsealedValueType = $cb($unsealedValueType);
if ($newUnsealedKeyType !== $unsealedKeyType || $newUnsealedValueType !== $unsealedValueType) {
$stillOriginal = false;
Expand All @@ -304,7 +341,7 @@ public function traverse(callable $cb): Type
return $this;
}

return self::create($items, $unsealed, $this->kind, $this->hasCallableItem);
return self::create($items, $unsealed, $this->kind);
}

public function traverseSimultaneously(Type $right, callable $cb): Type
Expand Down Expand Up @@ -333,7 +370,9 @@ public function traverseSimultaneously(Type $right, callable $cb): Type
$unsealed = $this->unsealed;
if ($unsealed !== null && $right->unsealed !== null) {
[$unsealedKeyType, $unsealedValueType] = $unsealed;
$newUnsealedKeyType = $cb($unsealedKeyType, $right->unsealed[0]);
$newUnsealedKeyType = $unsealedKeyType !== null && $right->unsealed[0] !== null
? $cb($unsealedKeyType, $right->unsealed[0])
: $unsealedKeyType;
$newUnsealedValueType = $cb($unsealedValueType, $right->unsealed[1]);
if ($newUnsealedKeyType !== $unsealedKeyType || $newUnsealedValueType !== $unsealedValueType) {
$stillOriginal = false;
Expand All @@ -345,7 +384,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type
return $this;
}

return self::create($items, $unsealed, $this->kind, $this->hasCallableItem);
return self::create($items, $unsealed, $this->kind);
}

public function toPhpDocNode(): TypeNode
Expand All @@ -364,13 +403,23 @@ public function toPhpDocNode(): TypeNode
}

[$unsealedKeyType, $unsealedValueType] = $this->unsealed;
if ($unsealedKeyType === null && $this->isImplicitMixed($unsealedValueType)) {
return ArrayShapeNode::createUnsealed($items, null, $this->kind);
}

return ArrayShapeNode::createUnsealed($items, new ArrayShapeUnsealedTypeNode(
$unsealedKeyType->toPhpDocNode(),
$unsealedValueType->toPhpDocNode(),
$unsealedKeyType?->toPhpDocNode(),
), $this->kind);
}

private function isImplicitMixed(Type $type): bool
{
return $type instanceof MixedType
&& !$type->isExplicitMixed()
&& $type->getSubtractedType() === null;
}

/**
* @return ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode
*/
Expand Down
26 changes: 26 additions & 0 deletions tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2229,6 +2229,32 @@ public function testGeneralizeValuesAlsoBroadensUnsealedValue(): void
);
}

public function testTraverseVisitsUnsealedKeyAndValue(): void
{
$type = new ConstantArrayType(
[new ConstantStringType('a')],
[new IntegerType()],
unsealed: [new StringType(), new BooleanType()],
);

$visited = [];
$result = $type->traverse(static function (Type $type) use (&$visited): Type {
$visited[] = $type->describe(VerbosityLevel::precise());

return new MixedType();
});

$this->assertSame(
['int', 'string', 'bool'],
$visited,
);

$this->assertSame(
'array{a: mixed, ...}',
$result->describe(VerbosityLevel::precise()),
);
}

public function testTraverseSimultaneouslyVisitsUnsealedKeyAndValue(): void
{
$left = new ConstantArrayType(
Expand Down
Loading