From edecc2abef111269fb01d32d8e73fc55e37766b3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 11:52:36 +0200 Subject: [PATCH 1/2] Preserve TemplateArrayType across offset writes and traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- src/Type/ArrayType.php | 19 ++++++++++--- src/Type/Generic/TemplateArrayType.php | 12 +++++++++ src/Type/IntersectionType.php | 27 +++++++++++++++++++ .../PHPStan/Rules/Functions/data/bug-3931.php | 2 +- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 288caefdc63..777343626d6 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -70,6 +70,17 @@ public function getItemType(): Type return $this->itemType; } + /** + * Build a same-kind array with new key/item types. Subclasses + * (e.g. {@see TemplateArrayType}) override this to preserve their + * extra metadata across array-mutating operations such as offset + * writes and unsets. + */ + protected function withTypes(Type $keyType, Type $itemType): self + { + return new self($keyType, $itemType); + } + public function getReferencedClasses(): array { return array_merge( @@ -354,7 +365,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni } return new IntersectionType([ - new self( + $this->withTypes( TypeCombinator::union($this->keyType, $offsetType), TypeCombinator::union($this->itemType, $valueType), ), @@ -364,7 +375,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni } return new IntersectionType([ - new self( + $this->withTypes( TypeCombinator::union($this->keyType, $offsetType), $unionValues ? TypeCombinator::union($this->itemType, $valueType) : $valueType, ), @@ -621,7 +632,7 @@ public function traverse(callable $cb): Type return new ConstantArrayType([], []); } - return new self($keyType, $itemType); + return $this->withTypes($keyType, $itemType); } return $this; @@ -664,7 +675,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type return new ConstantArrayType([], []); } - return new self($keyType, $itemType); + return $this->withTypes($keyType, $itemType); } return $this; diff --git a/src/Type/Generic/TemplateArrayType.php b/src/Type/Generic/TemplateArrayType.php index 3899bba47ae..62d47a36794 100644 --- a/src/Type/Generic/TemplateArrayType.php +++ b/src/Type/Generic/TemplateArrayType.php @@ -35,4 +35,16 @@ public function __construct( $this->default = $default; } + protected function withTypes(Type $keyType, Type $itemType): ArrayType + { + return new self( + $this->scope, + $this->strategy, + $this->variance, + $this->name, + new ArrayType($keyType, $itemType), + $this->default, + ); + } + } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 9dd35d0e60b..109d6f233a2 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -41,6 +41,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Enum\EnumCaseObjectType; +use PHPStan\Type\Generic\TemplateArrayType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; @@ -398,6 +399,21 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) $isList = $this->isList()->yes(); $isArray = $this->isArray()->yes(); $isNonEmptyArray = $this->isIterableAtLeastOnce()->yes(); + // When a TemplateArrayType carries the array refinement, we describe + // it via its own describe() (e.g. "T of array") rather than collapsing + // it into a generic `array<...>` prefix. In that case the + // `NonEmptyArrayType` and `AccessoryArrayListType` markers must + // describe themselves explicitly — they cannot be absorbed into a + // non-existent `non-empty-array` prefix. + $hasTemplateArray = false; + if ($isArray || $isList) { + foreach ($this->types as $type) { + if ($type instanceof TemplateArrayType) { + $hasTemplateArray = true; + break; + } + } + } $describedTypes = []; foreach ($this->getSortedTypes() as $i => $type) { if ($type instanceof AccessoryNonEmptyStringType @@ -436,6 +452,14 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) continue; } if ($isList || $isArray) { + if ($type instanceof TemplateArrayType) { + // Preserve the template's own describe (e.g. "T of array") + // instead of collapsing it to a generic array shape — the + // other intersection members already carry the array + // refinement. + $describedTypes[$i] = $type->describe($level); + continue; + } if ($type instanceof ArrayType) { $keyType = $type->getKeyType(); $valueType = $type->getItemType(); @@ -473,6 +497,9 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) continue; } if ($type instanceof NonEmptyArrayType || $type instanceof AccessoryArrayListType) { + if ($hasTemplateArray) { + $describedTypes[$i] = $type->describe($level); + } continue; } } diff --git a/tests/PHPStan/Rules/Functions/data/bug-3931.php b/tests/PHPStan/Rules/Functions/data/bug-3931.php index 424c7ca236f..d5eb4d83a3a 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -11,7 +11,7 @@ */ function addSomeKey(array $arr, int $value): array { $arr['mykey'] = $value; - assertType("non-empty-array&hasOffsetValue('mykey', int)", $arr); // should preserve T + assertType("T of array (function Bug3931\\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $arr); return $arr; } From 86fc85e5afeec76a5ca9ef1c1329d7e05c796797 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 12:25:58 +0200 Subject: [PATCH 2/2] Add regression test for #10749 Closes https://github.com/phpstan/phpstan/issues/10749 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TypesAssignedToPropertiesRuleTest.php | 5 ++++ .../Rules/Properties/data/bug-10749.php | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-10749.php diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 1f002bd5455..ab2f29903f1 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1066,4 +1066,9 @@ public function testBug10924(): void $this->analyse([__DIR__ . '/../Methods/data/bug-10924.php'], []); } + public function testBug10749(): void + { + $this->analyse([__DIR__ . '/data/bug-10749.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-10749.php b/tests/PHPStan/Rules/Properties/data/bug-10749.php new file mode 100644 index 00000000000..76958e3da02 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-10749.php @@ -0,0 +1,27 @@ + + * @implements ArrayAccess, value-of> + */ +abstract class Base implements ArrayAccess +{ + + /** @var T */ + protected array $data = []; + + /** + * @template K of key-of + * @param K|null $offset + * @param T[K] $value + */ + public function offsetSet($offset, $value): void + { + $this->data[$offset] = $value; + } + +}