Skip to content

Commit f63a13f

Browse files
ondrejmirtesclaude
andcommitted
Preserve TemplateArrayType across offset writes and traversal
`ArrayType::setOffsetValueType`, `traverse`, and `traverseSimultaneously` constructed the result via `new self(...)`, which always created a plain `ArrayType` even when called on a `TemplateArrayType`. The template wrapping was dropped — `$arr['mykey'] = $value` on a `T of array` returned `non-empty-array & hasOffsetValue('mykey', int)`, losing T. Add a protected `withTypes(Type, Type): self` factory that subclasses override (mirrors the `recreate()` pattern in `ConstantArrayType` / `TemplateConstantArrayType` and `GenericObjectType` / `TemplateGenericObjectType`) and route the affected `new self(...)` sites through it. `TemplateArrayType::withTypes` rebuilds the template wrapper around the new bound, so T is preserved through these operations. `unsetOffset`, `setExistingOffsetValueType`, and `generalizeValues` keep their plain `new self(...)` — those operations can break a more specific `T`'s contract, and the existing return-type diagnostic (see bug-6568) relies on the widening. `IntersectionType::describeItself` was also collapsing `TemplateArrayType` into a generic `non-empty-array<...>` prefix. Special-case it so the template's own describe ("T of array") survives, and emit `non-empty-array` / `list` separately when the template carries the array refinement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 92c2930 commit f63a13f

4 files changed

Lines changed: 55 additions & 5 deletions

File tree

src/Type/ArrayType.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ public function getItemType(): Type
7070
return $this->itemType;
7171
}
7272

73+
/**
74+
* Build a same-kind array with new key/item types. Subclasses
75+
* (e.g. {@see TemplateArrayType}) override this to preserve their
76+
* extra metadata across array-mutating operations such as offset
77+
* writes and unsets.
78+
*/
79+
protected function withTypes(Type $keyType, Type $itemType): self
80+
{
81+
return new self($keyType, $itemType);
82+
}
83+
7384
public function getReferencedClasses(): array
7485
{
7586
return array_merge(
@@ -354,7 +365,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
354365
}
355366

356367
return new IntersectionType([
357-
new self(
368+
$this->withTypes(
358369
TypeCombinator::union($this->keyType, $offsetType),
359370
TypeCombinator::union($this->itemType, $valueType),
360371
),
@@ -364,7 +375,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
364375
}
365376

366377
return new IntersectionType([
367-
new self(
378+
$this->withTypes(
368379
TypeCombinator::union($this->keyType, $offsetType),
369380
$unionValues ? TypeCombinator::union($this->itemType, $valueType) : $valueType,
370381
),
@@ -621,7 +632,7 @@ public function traverse(callable $cb): Type
621632
return new ConstantArrayType([], []);
622633
}
623634

624-
return new self($keyType, $itemType);
635+
return $this->withTypes($keyType, $itemType);
625636
}
626637

627638
return $this;
@@ -664,7 +675,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type
664675
return new ConstantArrayType([], []);
665676
}
666677

667-
return new self($keyType, $itemType);
678+
return $this->withTypes($keyType, $itemType);
668679
}
669680

670681
return $this;

src/Type/Generic/TemplateArrayType.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,16 @@ public function __construct(
3535
$this->default = $default;
3636
}
3737

38+
protected function withTypes(Type $keyType, Type $itemType): ArrayType
39+
{
40+
return new self(
41+
$this->scope,
42+
$this->strategy,
43+
$this->variance,
44+
$this->name,
45+
new ArrayType($keyType, $itemType),
46+
$this->default,
47+
);
48+
}
49+
3850
}

src/Type/IntersectionType.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use PHPStan\Type\Constant\ConstantIntegerType;
4242
use PHPStan\Type\Constant\ConstantStringType;
4343
use PHPStan\Type\Enum\EnumCaseObjectType;
44+
use PHPStan\Type\Generic\TemplateArrayType;
4445
use PHPStan\Type\Generic\TemplateType;
4546
use PHPStan\Type\Generic\TemplateTypeMap;
4647
use PHPStan\Type\Generic\TemplateTypeVariance;
@@ -398,6 +399,21 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
398399
$isList = $this->isList()->yes();
399400
$isArray = $this->isArray()->yes();
400401
$isNonEmptyArray = $this->isIterableAtLeastOnce()->yes();
402+
// When a TemplateArrayType carries the array refinement, we describe
403+
// it via its own describe() (e.g. "T of array") rather than collapsing
404+
// it into a generic `array<...>` prefix. In that case the
405+
// `NonEmptyArrayType` and `AccessoryArrayListType` markers must
406+
// describe themselves explicitly — they cannot be absorbed into a
407+
// non-existent `non-empty-array` prefix.
408+
$hasTemplateArray = false;
409+
if ($isArray || $isList) {
410+
foreach ($this->types as $type) {
411+
if ($type instanceof TemplateArrayType) {
412+
$hasTemplateArray = true;
413+
break;
414+
}
415+
}
416+
}
401417
$describedTypes = [];
402418
foreach ($this->getSortedTypes() as $i => $type) {
403419
if ($type instanceof AccessoryNonEmptyStringType
@@ -436,6 +452,14 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
436452
continue;
437453
}
438454
if ($isList || $isArray) {
455+
if ($type instanceof TemplateArrayType) {
456+
// Preserve the template's own describe (e.g. "T of array")
457+
// instead of collapsing it to a generic array shape — the
458+
// other intersection members already carry the array
459+
// refinement.
460+
$describedTypes[$i] = $type->describe($level);
461+
continue;
462+
}
439463
if ($type instanceof ArrayType) {
440464
$keyType = $type->getKeyType();
441465
$valueType = $type->getItemType();
@@ -473,6 +497,9 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
473497
continue;
474498
}
475499
if ($type instanceof NonEmptyArrayType || $type instanceof AccessoryArrayListType) {
500+
if ($hasTemplateArray) {
501+
$describedTypes[$i] = $type->describe($level);
502+
}
476503
continue;
477504
}
478505
}

tests/PHPStan/Rules/Functions/data/bug-3931.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212
function addSomeKey(array $arr, int $value): array {
1313
$arr['mykey'] = $value;
14-
assertType("non-empty-array&hasOffsetValue('mykey', int)", $arr); // should preserve T
14+
assertType("T of array (function Bug3931\\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $arr);
1515
return $arr;
1616
}
1717

0 commit comments

Comments
 (0)