@@ -715,7 +715,15 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
715715 return $ builder ->getArray ();
716716 }
717717
718- public function unsetOffset (Type $ offsetType ): Type
718+ /**
719+ * Removes or marks as optional the key(s) matching the given offset type from this constant array.
720+ *
721+ * By default, the method assumes an actual `unset()` call was made, which actively modifies the
722+ * array and weakens its list certainty to "maybe". However, in some contexts, such as the else
723+ * branch of an array_key_exists() check, the key is statically known to be absent without any
724+ * modification, so list certainty should be preserved as-is.
725+ */
726+ public function unsetOffset (Type $ offsetType , bool $ preserveListCertainty = false ): Type
719727 {
720728 $ offsetType = $ offsetType ->toArrayKey ();
721729 if ($ offsetType instanceof ConstantIntegerType || $ offsetType instanceof ConstantStringType) {
@@ -749,6 +757,11 @@ public function unsetOffset(Type $offsetType): Type
749757 $ this ->isList ,
750758 in_array ($ i , $ this ->optionalKeys , true ),
751759 );
760+ if (!$ preserveListCertainty ) {
761+ $ newIsList = $ newIsList ->and (TrinaryLogic::createMaybe ());
762+ } elseif ($ this ->isList ->yes () && $ newIsList ->no ()) {
763+ return new NeverType ();
764+ }
752765
753766 return new self ($ newKeyTypes , $ newValueTypes , $ this ->nextAutoIndexes , $ newOptionalKeys , $ newIsList );
754767 }
@@ -791,6 +804,11 @@ public function unsetOffset(Type $offsetType): Type
791804 $ this ->isList ,
792805 count ($ optionalKeys ) === count ($ this ->optionalKeys ),
793806 );
807+ if (!$ preserveListCertainty ) {
808+ $ newIsList = $ newIsList ->and (TrinaryLogic::createMaybe ());
809+ } elseif ($ this ->isList ->yes () && $ newIsList ->no ()) {
810+ return new NeverType ();
811+ }
794812
795813 return new self ($ this ->keyTypes , $ this ->valueTypes , $ this ->nextAutoIndexes , $ optionalKeys , $ newIsList );
796814 }
@@ -816,6 +834,11 @@ public function unsetOffset(Type $offsetType): Type
816834 $ this ->isList ,
817835 count ($ optionalKeys ) === count ($ this ->optionalKeys ),
818836 );
837+ if (!$ preserveListCertainty ) {
838+ $ newIsList = $ newIsList ->and (TrinaryLogic::createMaybe ());
839+ } elseif ($ this ->isList ->yes () && $ newIsList ->no ()) {
840+ return new NeverType ();
841+ }
819842
820843 return new self ($ this ->keyTypes , $ this ->valueTypes , $ this ->nextAutoIndexes , $ optionalKeys , $ newIsList );
821844 }
@@ -851,7 +874,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK
851874 }
852875 }
853876
854- return TrinaryLogic:: createMaybe () ;
877+ return $ arrayIsList ;
855878 }
856879
857880 public function chunkArray (Type $ lengthType , TrinaryLogic $ preserveKeys ): Type
@@ -1531,7 +1554,9 @@ private function getKeysOrValuesArray(array $types): self
15311554
15321555 public function describe (VerbosityLevel $ level ): string
15331556 {
1534- $ describeValue = function (bool $ truncate ) use ($ level ): string {
1557+ $ arrayName = $ this ->shouldBeDescribedAsAList () ? 'list ' : 'array ' ;
1558+
1559+ $ describeValue = function (bool $ truncate ) use ($ level , $ arrayName ): string {
15351560 $ items = [];
15361561 $ values = [];
15371562 $ exportValuesOnly = true ;
@@ -1570,18 +1595,36 @@ public function describe(VerbosityLevel $level): string
15701595 }
15711596
15721597 return sprintf (
1573- 'array{%s%s} ' ,
1598+ '%s{%s%s} ' ,
1599+ $ arrayName ,
15741600 implode (', ' , $ exportValuesOnly ? $ values : $ items ),
15751601 $ append ,
15761602 );
15771603 };
15781604 return $ level ->handle (
1579- fn (): string => $ this ->isIterableAtLeastOnce ()->no () ? ' array ' : sprintf ('array <%s, %s> ' , $ this ->getIterableKeyType ()->describe ($ level ), $ this ->getIterableValueType ()->describe ($ level )),
1605+ fn (): string => $ this ->isIterableAtLeastOnce ()->no () ? $ arrayName : sprintf ('%s <%s, %s> ' , $ arrayName , $ this ->getIterableKeyType ()->describe ($ level ), $ this ->getIterableValueType ()->describe ($ level )),
15801606 static fn (): string => $ describeValue (true ),
15811607 static fn (): string => $ describeValue (false ),
15821608 );
15831609 }
15841610
1611+ private function shouldBeDescribedAsAList (): bool
1612+ {
1613+ if (!$ this ->isList ->yes ()) {
1614+ return false ;
1615+ }
1616+
1617+ if (count ($ this ->optionalKeys ) === 0 ) {
1618+ return false ;
1619+ }
1620+
1621+ if (count ($ this ->optionalKeys ) > 1 ) {
1622+ return true ;
1623+ }
1624+
1625+ return $ this ->optionalKeys [0 ] !== count ($ this ->keyTypes ) - 1 ;
1626+ }
1627+
15851628 public function inferTemplateTypes (Type $ receivedType ): TemplateTypeMap
15861629 {
15871630 if ($ receivedType instanceof UnionType || $ receivedType instanceof IntersectionType) {
@@ -1643,11 +1686,11 @@ public function tryRemove(Type $typeToRemove): ?Type
16431686 }
16441687
16451688 if ($ typeToRemove instanceof HasOffsetType) {
1646- return $ this ->unsetOffset ($ typeToRemove ->getOffsetType ());
1689+ return $ this ->unsetOffset ($ typeToRemove ->getOffsetType (), true );
16471690 }
16481691
16491692 if ($ typeToRemove instanceof HasOffsetValueType) {
1650- return $ this ->unsetOffset ($ typeToRemove ->getOffsetType ());
1693+ return $ this ->unsetOffset ($ typeToRemove ->getOffsetType (), true );
16511694 }
16521695
16531696 return null ;
@@ -1823,6 +1866,19 @@ public function makeOffsetRequired(Type $offsetType): self
18231866 return $ this ;
18241867 }
18251868
1869+ public function makeList (): Type
1870+ {
1871+ if ($ this ->isList ->yes ()) {
1872+ return $ this ;
1873+ }
1874+
1875+ if ($ this ->isList ->no ()) {
1876+ return new NeverType ();
1877+ }
1878+
1879+ return new self ($ this ->keyTypes , $ this ->valueTypes , $ this ->nextAutoIndexes , $ this ->optionalKeys , TrinaryLogic::createYes ());
1880+ }
1881+
18261882 public function toPhpDocNode (): TypeNode
18271883 {
18281884 $ items = [];
@@ -1863,7 +1919,10 @@ public function toPhpDocNode(): TypeNode
18631919 );
18641920 }
18651921
1866- return ArrayShapeNode::createSealed ($ exportValuesOnly ? $ values : $ items );
1922+ return ArrayShapeNode::createSealed (
1923+ $ exportValuesOnly ? $ values : $ items ,
1924+ $ this ->shouldBeDescribedAsAList () ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY ,
1925+ );
18671926 }
18681927
18691928 public static function isValidIdentifier (string $ value ): bool
0 commit comments