diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index e828e91195..292752a53e 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -33,7 +33,6 @@ use PHPStan\Type\Traits\MaybeStringTypeTrait; use PHPStan\Type\Traits\NonGeneralizableTypeTrait; use PHPStan\Type\Traits\NonGenericTypeTrait; -use PHPStan\Type\Traits\NonRemoveableTypeTrait; use PHPStan\Type\Traits\TruthyBooleanTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonCompoundTypeTrait; use PHPStan\Type\Type; @@ -57,7 +56,6 @@ class HasOffsetValueType implements CompoundType, AccessoryType use TruthyBooleanTypeTrait; use NonGenericTypeTrait; use UndecidedComparisonCompoundTypeTrait; - use NonRemoveableTypeTrait; use NonGeneralizableTypeTrait; public function __construct(private ConstantStringType|ConstantIntegerType $offsetType, private Type $valueType) @@ -213,6 +211,22 @@ public function unsetOffset(Type $offsetType): Type return $this; } + public function tryRemove(Type $typeToRemove): ?Type + { + // Only reachable through TypeCombinator::remove(), which short-circuits the + // full-overlap case to NeverType and the disjoint case to the unchanged type + // before delegating here. So when we get here the value types always partially + // overlap and removing $typeToRemove's value type genuinely narrows ours. + if ($typeToRemove instanceof self && $this->offsetType->equals($typeToRemove->getOffsetType())) { + return new self( + $this->offsetType, + TypeCombinator::remove($this->valueType, $typeToRemove->getValueType()), + ); + } + + return null; + } + public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type { return $this->getKeysArray(); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 7f1e47406b..840a99106c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2629,7 +2629,52 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } - if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove instanceof HasOffsetValueType) { + $offsetType = $typeToRemove->getOffsetType(); + $valueTypeToRemove = $typeToRemove->getValueType(); + + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType->getValue() !== $offsetType->getValue()) { + continue; + } + + $currentValueType = $this->valueTypes[$i]; + $valueIsSuperType = $valueTypeToRemove->isSuperTypeOf($currentValueType); + + if ($valueIsSuperType->no()) { + return null; + } + + if ($valueIsSuperType->yes()) { + $unsetResult = $this->unsetOffset($offsetType, true); + // When the source was definitely a list but the post-unset shape + // definitely isn't (e.g. unsetting a non-optional leading key + // creates a hole), no value of $this could have lacked the + // removed key — the subtraction yields the empty set. + if ($this->isList->yes() && $unsetResult->isList()->no()) { + return new NeverType(); + } + return $unsetResult; + } + + $newValueType = TypeCombinator::remove($currentValueType, $valueTypeToRemove); + $valueTypes = $this->valueTypes; + $valueTypes[$i] = $newValueType; + + return $this->recreate( + $this->keyTypes, + $valueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + $this->isList, + $this->unsealed, + ); + } + + return null; + } + + if ($typeToRemove instanceof HasOffsetType) { $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); // When the source was definitely a list but the post-unset shape // definitely isn't (e.g. unsetting a non-optional leading key diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 4fc1dff683..149d6fcbdb 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -6185,6 +6185,67 @@ public static function dataRemove(): array ArrayType::class, 'array<0|string, mixed>', ], + // HasOffsetValueType with partial value overlap — narrow value, keep key mandatory + [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new UnionType([new StringType(), new IntegerType()])], + ), + new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()), + ConstantArrayType::class, + 'array{a: string}', + ], + // HasOffsetValueType with partial value overlap — narrow value, keep key optional + [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new UnionType([new StringType(), new IntegerType()])], + [0], + [0], + ), + new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()), + ConstantArrayType::class, + 'array{a?: string}', + ], + // HasOffsetValueType with full value overlap on optional key — remove key entirely + [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new IntegerType()], + [0], + [0], + ), + new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()), + ConstantArrayType::class, + 'array{}', + ], + // HasOffsetValueType with partial value overlap — multi-key array + [ + new ConstantArrayType( + [new ConstantStringType('a'), new ConstantStringType('b')], + [new UnionType([new StringType(), new IntegerType()]), new StringType()], + ), + new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()), + ConstantArrayType::class, + 'array{a: string, b: string}', + ], + // HasOffsetValueType removing another HasOffsetValueType with same offset + [ + new HasOffsetValueType(new ConstantStringType('a'), new UnionType([new StringType(), new IntegerType()])), + new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()), + HasOffsetValueType::class, + 'hasOffsetValue(\'a\', string)', + ], + // HasOffsetValueType on IntersectionType — narrow value type through intersection + [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new HasOffsetValueType(new ConstantStringType('a'), new UnionType([new StringType(), new IntegerType()])), + ]), + new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()), + IntersectionType::class, + 'non-empty-array&hasOffsetValue(\'a\', string)', + ], ]; }