Repository: dedoc/scramble (php)

Issue:
Documenting tuples and example for schemas
- [x] Add `@example` support into response schemas 
- [x] Add tuple support

Expected API:
```php
'geometry' => [
    'type' => 'Point',
     /** 
      * @var array{float, float}
      * @example [34.232, 48.32987]
      */
     'coordinates' => [$this->lng, $this->lat]
]
```
 

### Discussed in https://github.com/dedoc/scramble/discussions/213

<div type='discussions-op-text'>

<sup>Originally posted by **tomhelmer** August 29, 2023</sup>
Hi, I'm documenting an API with GeoJSON where 'coordinates' is a float tuple. 

See example on the frontpage: https://geojson.org/

The closest I can get is using a docblock comment :

```
'geometry' => [
                'type' => 'Point',
                /** @var float[] */
                'coordinates' => [$this->lng, $this->lat]
            ],
```

But it results in:

```
"geometry": {
    "type": "Point",
    "coordinates": [
      0
    ]
  },
```

Is there a way to manually add the example data ? ( or some other means of solving this )
</div>

Pull request description:
Add tuples support
fixes #218

Patch:
diff --git a/routes/web.php b/routes/web.php
index 58bacb6..4442789 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Route;
 
 Route::middleware(config('scramble.middleware', [RestrictedDocsAccess::class]))->group(function () {
     Route::get('docs/api.json', function (Dedoc\Scramble\Generator $generator) {
-        return $generator();
+        return response()->json($generator(), options: JSON_PRETTY_PRINT);
     })->name('scramble.docs.index');
 
     Route::view('docs/api', 'scramble::docs')->name('scramble.docs.api');
diff --git a/src/Infer/Handler/ArrayHandler.php b/src/Infer/Handler/ArrayHandler.php
index 53585a6..f650416 100644
--- a/src/Infer/Handler/ArrayHandler.php
+++ b/src/Infer/Handler/ArrayHandler.php
@@ -3,7 +3,7 @@
 namespace Dedoc\Scramble\Infer\Handler;
 
 use Dedoc\Scramble\Infer\Scope\Scope;
-use Dedoc\Scramble\Support\Type\ArrayType;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
 use Dedoc\Scramble\Support\Type\TypeHelper;
 use PhpParser\Node;
 
@@ -23,7 +23,7 @@ class ArrayHandler
 
         $scope->setType(
             $node,
-            TypeHelper::unpackIfArrayType(new ArrayType($arrayItems))
+            TypeHelper::unpackIfArray(new KeyedArrayType($arrayItems))
         );
     }
 }
diff --git a/src/Infer/Handler/ArrayItemHandler.php b/src/Infer/Handler/ArrayItemHandler.php
index 6069c3f..60bdcad 100644
--- a/src/Infer/Handler/ArrayItemHandler.php
+++ b/src/Infer/Handler/ArrayItemHandler.php
@@ -20,8 +20,8 @@ class ArrayItemHandler
             new ArrayItemType_(
                 $node->key->value ?? null,
                 $scope->getType($node->value),
-                $isOptional = false,
-                $shouldUnpack = $node->unpack,
+                isOptional: false,
+                shouldUnpack: $node->unpack,
             )
         );
     }
diff --git a/src/PhpDoc/PhpDocTypeHelper.php b/src/PhpDoc/PhpDocTypeHelper.php
index 3e5c3c9..72d5ee2 100644
--- a/src/PhpDoc/PhpDocTypeHelper.php
+++ b/src/PhpDoc/PhpDocTypeHelper.php
@@ -9,6 +9,7 @@ use Dedoc\Scramble\Support\Type\FloatType;
 use Dedoc\Scramble\Support\Type\Generic;
 use Dedoc\Scramble\Support\Type\IntegerType;
 use Dedoc\Scramble\Support\Type\IntersectionType;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
 use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType;
 use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType;
 use Dedoc\Scramble\Support\Type\Literal\LiteralStringType;
@@ -39,7 +40,7 @@ class PhpDocTypeHelper
         }
 
         if ($type instanceof ArrayShapeNode) {
-            return new ArrayType(array_map(
+            return new KeyedArrayType(array_map(
                 function (ArrayShapeItemNode $t) {
                     $keyName = $t->keyName instanceof IdentifierTypeNode
                         ? $t->keyName->name
@@ -56,19 +57,23 @@ class PhpDocTypeHelper
         }
 
         if ($type instanceof ArrayTypeNode) {
-            return new ArrayType([
-                new ArrayItemType_(0, static::toType($type->type)),
-            ]);
+            return new ArrayType(
+                value: static::toType($type->type),
+            );
         }
 
         if ($type instanceof GenericTypeNode) {
             if ($type->type->name === 'array') {
-                return static::toType(new ArrayShapeNode(
-                    array_map(
-                        fn ($type) => new ArrayShapeItemNode(null, false, $type),
-                        $type->genericTypes,
-                    )
-                ));
+                if (count($type->genericTypes) === 1) {
+                    return new ArrayType(
+                        value: static::toType($type->genericTypes[0]),
+                    );
+                }
+
+                return new ArrayType(
+                    value: static::toType($type->genericTypes[1]),
+                    key: static::toType($type->genericTypes[0]),
+                );
             }
 
             if (! ($typeObject = static::toType($type->type)) instanceof ObjectType) {
diff --git a/src/Support/Generator/TypeTransformer.php b/src/Support/Generator/TypeTransformer.php
index abd4a2f..ddaee0c 100644
--- a/src/Support/Generator/TypeTransformer.php
+++ b/src/Support/Generator/TypeTransformer.php
@@ -58,34 +58,23 @@ class TypeTransformer
         $openApiType = new StringType();
 
         if (
-            $type instanceof \Dedoc\Scramble\Support\Type\ArrayType
-            && (
-                (collect($type->items)->every(fn ($t) => is_numeric($t->key)) && collect($type->items)->count() === 1)
-                || collect($type->items)->every(fn ($t) => $t->key === null)
-            )
+            $type instanceof \Dedoc\Scramble\Support\Type\KeyedArrayType
+            && $type->isList
         ) {
-            $isMap = collect($type->items)->every(fn ($t) => $t->key === null)
-                && count($type->items) === 2;
-
-            if ($isMap) {
-                $keyType = $this->transform($type->items[0]->value);
-
-                if ($keyType instanceof IntegerType) {
-                    $openApiType = (new ArrayType)
-                        ->setItems($this->transform($type->items[1]->value));
-                } else {
-                    $openApiType = (new ObjectType)
-                        ->additionalProperties($this->transform($type->items[1]->value));
-                }
-            } else {
-                $itemsType = isset($type->items[0])
-                    ? $this->transform($type->items[0]->value)
-                    : new StringType();
-
-                $openApiType = (new ArrayType())->setItems($itemsType);
-            }
+            /** @see https://stackoverflow.com/questions/57464633/how-to-define-a-json-array-with-concrete-item-definition-for-every-index-i-e-a */
+            $openApiType = (new ArrayType)
+                ->setMin(count($type->items))
+                ->setMax(count($type->items))
+                ->setPrefixItems(
+                    array_map(
+                        fn ($item) => $this->transform($item->value),
+                        $type->items
+                    )
+                )
+                ->setAdditionalItems(false);
         } elseif (
-            $type instanceof \Dedoc\Scramble\Support\Type\ArrayType
+            $type instanceof \Dedoc\Scramble\Support\Type\KeyedArrayType
+            && ! $type->isList
         ) {
             $openApiType = new ObjectType();
             $requiredKeys = [];
@@ -104,6 +93,17 @@ class TypeTransformer
             $openApiType->properties = $props->all();
 
             $openApiType->setRequired($requiredKeys);
+        } elseif (
+            $type instanceof \Dedoc\Scramble\Support\Type\ArrayType
+        ) {
+            $keyType = $this->transform($type->key);
+
+            if ($keyType instanceof IntegerType) {
+                $openApiType = (new ArrayType)->setItems($this->transform($type->value));
+            } else {
+                $openApiType = (new ObjectType)
+                    ->additionalProperties($this->transform($type->value));
+            }
         } elseif ($type instanceof ArrayItemType_) {
             $openApiType = $this->transform($type->value);
 
diff --git a/src/Support/Generator/Types/ArrayType.php b/src/Support/Generator/Types/ArrayType.php
index 0625dd5..be81dfa 100644
--- a/src/Support/Generator/Types/ArrayType.php
+++ b/src/Support/Generator/Types/ArrayType.php
@@ -9,14 +9,23 @@ class ArrayType extends Type
     /** @var Type|Schema */
     public $items;
 
+    /** @var Type|Schema */
+    public $prefixItems = [];
+
     private $minItems = null;
 
     private $maxItems = null;
 
+    private $additionalItems = null;
+
     public function __construct()
     {
         parent::__construct('array');
-        $this->items = new StringType;
+
+        $defaultMissingType = new StringType;
+        $defaultMissingType->setAttribute('missing', true);
+
+        $this->items = $defaultMissingType;
     }
 
     public function setMin($min)
@@ -33,24 +42,45 @@ class ArrayType extends Type
         return $this;
     }
 
+    public function setItems($items)
+    {
+        $this->items = $items;
+
+        return $this;
+    }
+
+    public function setPrefixItems($prefixItems)
+    {
+        $this->prefixItems = $prefixItems;
+
+        return $this;
+    }
+
+    public function setAdditionalItems($additionalItems)
+    {
+        $this->additionalItems = $additionalItems;
+
+        return $this;
+    }
+
     public function toArray()
     {
+        $shouldOmitItems = $this->items->getAttribute('missing')
+            && count($this->prefixItems);
+
         return array_merge(
             parent::toArray(),
-            [
+            $shouldOmitItems ? [] : [
                 'items' => $this->items->toArray(),
             ],
+            $this->prefixItems ? [
+                'prefixItems' => array_map(fn ($item) => $item->toArray(), $this->prefixItems),
+            ] : [],
             array_filter([
                 'minItems' => $this->minItems,
                 'maxItems' => $this->maxItems,
+                'additionalItems' => $this->additionalItems,
             ], fn ($v) => $v !== null)
         );
     }
-
-    public function setItems($items)
-    {
-        $this->items = $items;
-
-        return $this;
-    }
 }
diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php
index 9eac78c..5fe1dd7 100644
--- a/src/Support/InferExtensions/ModelExtension.php
+++ b/src/Support/InferExtensions/ModelExtension.php
@@ -15,6 +15,7 @@ use Dedoc\Scramble\Support\Type\BooleanType;
 use Dedoc\Scramble\Support\Type\FloatType;
 use Dedoc\Scramble\Support\Type\Generic;
 use Dedoc\Scramble\Support\Type\IntegerType;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
 use Dedoc\Scramble\Support\Type\NullType;
 use Dedoc\Scramble\Support\Type\ObjectType;
 use Dedoc\Scramble\Support\Type\StringType;
@@ -153,9 +154,9 @@ class ModelExtension implements MethodReturnTypeExtension, PropertyTypeExtension
                 return $event->getInstance()->getPropertyType($name);
             });
 
-        return new ArrayType([
+        return new KeyedArrayType([
             ...$arrayableAttributesTypes->map(fn ($type, $name) => new ArrayItemType_($name, $type))->values()->all(),
-            ...$arrayableRelationsTypes->map(fn ($type, $name) => new ArrayItemType_($name, $type, $isOptional = true))->values()->all(),
+            ...$arrayableRelationsTypes->map(fn ($type, $name) => new ArrayItemType_($name, $type, isOptional: true))->values()->all(),
         ]);
     }
 
diff --git a/src/Support/InferExtensions/ResourceCollectionTypeInfer.php b/src/Support/InferExtensions/ResourceCollectionTypeInfer.php
index 2d995b9..bc722e2 100644
--- a/src/Support/InferExtensions/ResourceCollectionTypeInfer.php
+++ b/src/Support/InferExtensions/ResourceCollectionTypeInfer.php
@@ -5,7 +5,6 @@ namespace Dedoc\Scramble\Support\InferExtensions;
 use Dedoc\Scramble\Infer\Definition\ClassDefinition;
 use Dedoc\Scramble\Infer\Extensions\ExpressionTypeInferExtension;
 use Dedoc\Scramble\Infer\Scope\Scope;
-use Dedoc\Scramble\Support\Type\ArrayItemType_;
 use Dedoc\Scramble\Support\Type\ArrayType;
 use Dedoc\Scramble\Support\Type\Literal\LiteralStringType;
 use Dedoc\Scramble\Support\Type\ObjectType;
@@ -53,9 +52,7 @@ class ResourceCollectionTypeInfer implements ExpressionTypeInferExtension
             return new UnknownType('Cannot find a type of the collecting class.');
         }
 
-        return new ArrayType([
-            new ArrayItemType_(0, new ObjectType($collectingClassType->value)),
-        ]);
+        return new ArrayType(value: new ObjectType($collectingClassType->value));
     }
 
     private function getCollectingClassType(ClassDefinition $classDefinition): ?LiteralStringType
diff --git a/src/Support/RouteInfo.php b/src/Support/RouteInfo.php
index 4ac197e..ecc3725 100644
--- a/src/Support/RouteInfo.php
+++ b/src/Support/RouteInfo.php
@@ -6,6 +6,7 @@ use Dedoc\Scramble\Infer;
 use Dedoc\Scramble\Infer\Reflector\MethodReflector;
 use Dedoc\Scramble\Infer\Services\FileParser;
 use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
+use Dedoc\Scramble\Support\Type\ArrayItemType_;
 use Dedoc\Scramble\Support\Type\BooleanType;
 use Dedoc\Scramble\Support\Type\FloatType;
 use Dedoc\Scramble\Support\Type\FunctionType;
@@ -163,6 +164,11 @@ class RouteInfo
                     || $type instanceof FloatType
                     || $type instanceof BooleanType
                     || $type instanceof NullType
+                    /*
+                     * Give some weight for keyed array item so when comparing `array<mixed>` to `array{foo: unknown}`,
+                     * the keyed array is preferred.
+                     */
+                    || $type instanceof ArrayItemType_ && is_string($type->key)
                 ) {
                     $this->count++;
                 }
diff --git a/src/Support/Type/ArrayType.php b/src/Support/Type/ArrayType.php
index 0fc6af6..e298e0a 100644
--- a/src/Support/Type/ArrayType.php
+++ b/src/Support/Type/ArrayType.php
@@ -4,19 +4,15 @@ namespace Dedoc\Scramble\Support\Type;
 
 class ArrayType extends AbstractType
 {
-    /**
-     * @var ArrayItemType_[]
-     */
-    public array $items = [];
-
-    public function __construct(array $items = [])
-    {
-        $this->items = $items;
+    public function __construct(
+        public Type $value = new MixedType,
+        public Type $key = new IntegerType,
+    ) {
     }
 
     public function nodes(): array
     {
-        return ['items'];
+        return ['value', 'key'];
     }
 
     public function isSame(Type $type)
@@ -26,24 +22,10 @@ class ArrayType extends AbstractType
 
     public function toString(): string
     {
-        $numIndex = 0;
-
-        return sprintf(
-            'array{%s}',
-            implode(', ', array_map(function (ArrayItemType_ $item) use (&$numIndex) {
-                $str = sprintf(
-                    '%s%s: %s',
-                    $item->isNumericKey() ? $numIndex : $item->key,
-                    $item->isOptional ? '?' : '',
-                    $item->value->toString()
-                );
-
-                if ($item->isNumericKey()) {
-                    $numIndex++;
-                }
+        if ($this->key instanceof IntegerType) {
+            return sprintf('array<%s>', $this->value->toString());
+        }
 
-                return $str;
-            }, $this->items))
-        );
+        return sprintf('array<%s, %s>', $this->key->toString(), $this->value->toString());
     }
 }
diff --git a/src/Support/Type/KeyedArrayType.php b/src/Support/Type/KeyedArrayType.php
new file mode 100644
index 0000000..7b625ab
--- /dev/null
+++ b/src/Support/Type/KeyedArrayType.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Dedoc\Scramble\Support\Type;
+
+/**
+ * Represents an array with known keys. This may represent a list as well.
+ */
+class KeyedArrayType extends AbstractType
+{
+    public bool $isList = false;
+
+    /**
+     * @param  ArrayItemType_[]  $items
+     */
+    public function __construct(
+        public array $items = [],
+        ?bool $isList = null
+    ) {
+        if ($isList === null) {
+            $this->isList = static::checkIsList($items);
+        } else {
+            $this->isList = $isList;
+        }
+    }
+
+    public static function checkIsList(array $items): bool
+    {
+        return collect($items)->every(fn (ArrayItemType_ $item) => $item->key === null)
+            || collect($items)->every(fn (ArrayItemType_ $item) => is_numeric($item->key)); // @todo add consecutive check to be sure it is really a list
+    }
+
+    public function nodes(): array
+    {
+        return ['items'];
+    }
+
+    public function isSame(Type $type)
+    {
+        return false;
+    }
+
+    public function toString(): string
+    {
+        $name = $this->isList ? 'list' : 'array';
+
+        $numIndex = 0;
+
+        return sprintf(
+            '%s{%s}',
+            $name,
+            implode(', ', array_map(function (ArrayItemType_ $item) use (&$numIndex) {
+                $str = $this->isList ? sprintf(
+                    '%s',
+                    $item->value->toString()
+                ) : sprintf(
+                    '%s%s: %s',
+                    $item->isNumericKey() ? $numIndex : $item->key,
+                    $item->isOptional ? '?' : '',
+                    $item->value->toString()
+                );
+
+                if ($item->isNumericKey()) {
+                    $numIndex++;
+                }
+
+                return $str;
+            }, $this->items))
+        );
+    }
+}
diff --git a/src/Support/Type/MixedType.php b/src/Support/Type/MixedType.php
new file mode 100644
index 0000000..a95ff8f
--- /dev/null
+++ b/src/Support/Type/MixedType.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Dedoc\Scramble\Support\Type;
+
+class MixedType extends AbstractType
+{
+    public function isSame(Type $type)
+    {
+        return false;
+    }
+
+    public function toString(): string
+    {
+        return 'mixed';
+    }
+}
diff --git a/src/Support/Type/TypeHelper.php b/src/Support/Type/TypeHelper.php
index d6381d4..7cf3f75 100644
--- a/src/Support/Type/TypeHelper.php
+++ b/src/Support/Type/TypeHelper.php
@@ -85,15 +85,15 @@ class TypeHelper
         return $matchingArg ? $scope->getType($matchingArg->value) : $default;
     }
 
-    public static function unpackIfArrayType($type)
+    public static function unpackIfArray($type)
     {
-        if (! $type instanceof ArrayType) {
+        if (! $type instanceof KeyedArrayType) {
             return $type;
         }
 
         $unpackedItems = collect($type->items)
             ->flatMap(function (ArrayItemType_ $type) {
-                if ($type->shouldUnpack && $type->value instanceof ArrayType) {
+                if ($type->shouldUnpack && $type->value instanceof KeyedArrayType) {
                     return $type->value->items;
                 }
 
@@ -109,7 +109,7 @@ class TypeHelper
                 return $arrayItems;
             }, []);
 
-        return new ArrayType(array_values($unpackedItems));
+        return new KeyedArrayType(array_values($unpackedItems));
     }
 
     /**
diff --git a/src/Support/Type/TypeWalker.php b/src/Support/Type/TypeWalker.php
index 75da83f..acd426c 100644
--- a/src/Support/Type/TypeWalker.php
+++ b/src/Support/Type/TypeWalker.php
@@ -44,10 +44,10 @@ class TypeWalker
         foreach ($propertiesWithNodes as $propertyWithNode) {
             $node = $subject->$propertyWithNode;
             if (! is_array($node)) {
-                $subject->$propertyWithNode = TypeHelper::unpackIfArrayType($this->replace($node, $replacer));
+                $subject->$propertyWithNode = TypeHelper::unpackIfArray($this->replace($node, $replacer));
             } else {
                 foreach ($node as $index => $item) {
-                    $subject->$propertyWithNode[$index] = TypeHelper::unpackIfArrayType($this->replace($item, $replacer));
+                    $subject->$propertyWithNode[$index] = TypeHelper::unpackIfArray($this->replace($item, $replacer));
                 }
             }
         }
diff --git a/src/Support/TypeToSchemaExtensions/EloquentCollectionToSchema.php b/src/Support/TypeToSchemaExtensions/EloquentCollectionToSchema.php
index fbafb96..ff7e6bf 100644
--- a/src/Support/TypeToSchemaExtensions/EloquentCollectionToSchema.php
+++ b/src/Support/TypeToSchemaExtensions/EloquentCollectionToSchema.php
@@ -3,7 +3,6 @@
 namespace Dedoc\Scramble\Support\TypeToSchemaExtensions;
 
 use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
-use Dedoc\Scramble\Support\Type\ArrayItemType_;
 use Dedoc\Scramble\Support\Type\ArrayType;
 use Dedoc\Scramble\Support\Type\Generic;
 use Dedoc\Scramble\Support\Type\Type;
@@ -25,9 +24,7 @@ class EloquentCollectionToSchema extends TypeToSchemaExtension
      */
     public function toSchema(Type $type)
     {
-        $type = new ArrayType([
-            new ArrayItemType_(0, $type->templateTypes[0]),
-        ]);
+        $type = new ArrayType(value: $type->templateTypes[0]);
 
         return $this->openApiTransformer->transform($type);
     }
diff --git a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php
index 2abdd52..cf75ae5 100644
--- a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php
+++ b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php
@@ -13,6 +13,7 @@ use Dedoc\Scramble\Support\InferExtensions\ResourceCollectionTypeInfer;
 use Dedoc\Scramble\Support\Type\ArrayItemType_;
 use Dedoc\Scramble\Support\Type\ArrayType;
 use Dedoc\Scramble\Support\Type\Generic;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
 use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType;
 use Dedoc\Scramble\Support\Type\ObjectType;
 use Dedoc\Scramble\Support\Type\StringType;
@@ -45,7 +46,7 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
             ? $def->type->getReturnType()
             : new \Dedoc\Scramble\Support\Type\UnknownType();
 
-        if (! $array instanceof ArrayType) {
+        if (! $array instanceof KeyedArrayType) {
             if ($type->isInstanceOf(ResourceCollection::class)) {
                 $array = (new ResourceCollectionTypeInfer)->getBasicCollectionType($definition);
             } else {
@@ -53,11 +54,17 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
             }
         }
 
-        if (! $array instanceof ArrayType) {
+        // The case when `toArray` is not defined.
+        if ($array instanceof ArrayType) {
+            return $this->openApiTransformer->transform($array);
+        }
+
+        if (! $array instanceof KeyedArrayType) {
             return new UnknownType();
         }
 
         $array->items = $this->flattenMergeValues($array->items);
+        $array->isList = KeyedArrayType::checkIsList($array->items);
 
         return $this->openApiTransformer->transform($array);
     }
@@ -66,8 +73,9 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
     {
         return collect($items)
             ->flatMap(function (ArrayItemType_ $item) {
-                if ($item->value instanceof ArrayType) {
+                if ($item->value instanceof KeyedArrayType) {
                     $item->value->items = $this->flattenMergeValues($item->value->items);
+                    $item->value->isList = KeyedArrayType::checkIsList($item->value->items);
 
                     return [$item];
                 }
@@ -131,9 +139,9 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
                 ) {
                     $arrayToMerge = $item->value->templateTypes[1];
 
-                    // Second generic argument of the `MergeValue` class must be an array.
+                    // Second generic argument of the `MergeValue` class must be a keyed array.
                     // Otherwise, we ignore it from the resulting array.
-                    if (! $arrayToMerge instanceof ArrayType) {
+                    if (! $arrayToMerge instanceof KeyedArrayType) {
                         return [];
                     }
 
@@ -168,16 +176,16 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
 
         $openApiType = $this->openApiTransformer->transform($type);
 
-        if (($withArray = $definition->getMethodCallType('with')) instanceof ArrayType) {
+        if (($withArray = $definition->getMethodCallType('with')) instanceof KeyedArrayType) {
             $withArray->items = $this->flattenMergeValues($withArray->items);
         }
-        if ($additional instanceof ArrayType) {
+        if ($additional instanceof KeyedArrayType) {
             $additional->items = $this->flattenMergeValues($additional->items);
         }
 
         $shouldWrap = ($wrapKey = $type->name::$wrap ?? null) !== null
-            || $withArray instanceof ArrayType
-            || $additional instanceof ArrayType;
+            || $withArray instanceof KeyedArrayType
+            || $additional instanceof KeyedArrayType;
         $wrapKey = $wrapKey ?: 'data';
 
         if ($shouldWrap) {
@@ -185,11 +193,11 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
                 ->addProperty($wrapKey, $openApiType)
                 ->setRequired([$wrapKey]);
 
-            if ($withArray instanceof ArrayType) {
+            if ($withArray instanceof KeyedArrayType) {
                 $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($withArray));
             }
 
-            if ($additional instanceof ArrayType) {
+            if ($additional instanceof KeyedArrayType) {
                 $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($additional));
             }
         }


Test patch:
diff --git a/tests/Infer/ClassDefinitionTest.php b/tests/Infer/ClassDefinitionTest.php
index 978c893..0bec7e3 100644
--- a/tests/Infer/ClassDefinitionTest.php
+++ b/tests/Infer/ClassDefinitionTest.php
@@ -122,7 +122,7 @@ class Foo {
 EOD)->getClassDefinition('Foo');
 
     expect($type->methods['getPropGetter']->type->toString())
-        ->toBe('<TProp1>(TProp1): <TProp2, TQ>(TProp2, TQ): array{0: TQ, 1: TProp2, 2: TProp}');
+        ->toBe('<TProp1>(TProp1): <TProp2, TQ>(TProp2, TQ): list{TQ, TProp2, TProp}');
 });
 
 it('generates definition for inheritance', function () {
diff --git a/tests/Infer/ArraySpreadTest.php b/tests/Infer/Handler/ArrayHandlerTest.php
similarity index 51%
rename from tests/Infer/ArraySpreadTest.php
rename to tests/Infer/Handler/ArrayHandlerTest.php
index 4194ac1..840e935 100644
--- a/tests/Infer/ArraySpreadTest.php
+++ b/tests/Infer/Handler/ArrayHandlerTest.php
@@ -1,10 +1,29 @@
 <?php
 
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
+
+it('infers keyed array shape type', function () {
+    expect($type = getStatementType("['foo' => 1, 'bar' => 'foo', 23]"))
+        ->toBeInstanceOf(KeyedArrayType::class)
+        ->and($type->toString())
+        ->toBe('array{foo: int(1), bar: string(foo), 0: int(23)}');
+});
+
+it('infers list type', function () {
+    expect($type = getStatementType("[1, 2, 'foo']"))
+        ->toBeInstanceOf(KeyedArrayType::class)
+        ->and($type->isList)
+        ->toBeTrue()
+        ->and($type->toString())
+        ->toBe('list{int(1), int(2), string(foo)}');
+});
+
 it('infers array spread in resulting type', function () {
     expect(getStatementType("[42, 'b' => 'foo', ...['a' => 1, 'b' => 'wow', 16], 23]")->toString())
         ->toBe('array{0: int(42), b: string(wow), a: int(1), 1: int(16), 2: int(23)}');
 });
 
+// @todo: Move test to reference resolving tests group
 it('infers array spread from other methods', function () {
     $type = analyzeFile(<<<'EOD'
 <?php
diff --git a/tests/Infer/ReferenceResolutionTest.php b/tests/Infer/ReferenceResolutionTest.php
index 8a77380..35a5e9d 100644
--- a/tests/Infer/ReferenceResolutionTest.php
+++ b/tests/Infer/ReferenceResolutionTest.php
@@ -90,7 +90,7 @@ class Foo {
 }
 EOD)->getExpressionType('(new Foo("wow"))->foo("prop", 42)(12)');
 
-    expect($type->toString())->toBe('array{0: string(wow), 1: int(12), 2: int(42)}');
+    expect($type->toString())->toBe('list{string(wow), int(12), int(42)}');
 });
 
 it('doesnt resolve templates from not own definition', function () {
diff --git a/tests/Infer/ReferenceTypesTest.php b/tests/Infer/ReferenceTypesTest.php
index 6354322..a666142 100644
--- a/tests/Infer/ReferenceTypesTest.php
+++ b/tests/Infer/ReferenceTypesTest.php
@@ -57,7 +57,7 @@ class Foo {
 }
 EOD)->getClassDefinition('Foo');
 
-    expect($type->methods['foo']->type->toString())->toBe('(): array{0: int(2), 1: int(2)}');
+    expect($type->methods['foo']->type->toString())->toBe('(): list{int(2), int(2)}');
 });
 
 it('resolves unknown references to unknowns in non-reference return types', function () {
@@ -73,7 +73,7 @@ class Foo {
 }
 EOD)->getClassDefinition('Foo');
 
-    expect($type->methods['foo']->type->toString())->toBe('(): array{0: int(2), 1: unknown}');
+    expect($type->methods['foo']->type->toString())->toBe('(): list{int(2), unknown}');
 });
 
 it('resolves a deep reference when encountered in self class', function () {
diff --git a/tests/PhpDoc/PhpDocTypeHelperTest.php b/tests/PhpDoc/PhpDocTypeHelperTest.php
index a62b979..c122ca3 100644
--- a/tests/PhpDoc/PhpDocTypeHelperTest.php
+++ b/tests/PhpDoc/PhpDocTypeHelperTest.php
@@ -19,3 +19,11 @@ it('parses php doc into type correctly', function (string $phpDocType, string $e
     ['/** @var Foo */', 'Foo'],
     ['/** @var Foo<Bar, Baz> */', 'Foo<Bar, Baz>'],
 ]);
+
+it('parses tuple', function (string $phpDocType, string $expectedTypeString) {
+    expect(
+        getPhpTypeFromDoc_Copy($phpDocType)->toString()
+    )->toBe($expectedTypeString);
+})->with([
+    ['/** @var array{float, float} */', 'list{float, float}'],
+]);
diff --git a/tests/ResponsesInferTypesTest.php b/tests/ResponsesInferTypesTest.php
index f092299..39185a1 100644
--- a/tests/ResponsesInferTypesTest.php
+++ b/tests/ResponsesInferTypesTest.php
@@ -6,11 +6,11 @@ it('infers response factory expressions', function (string $expression, string $
     expect($type->toString())->toBe($expectedType);
 })->with([
     ['response()', 'Illuminate\Contracts\Routing\ResponseFactory'],
-    ['response("hey", 401)', 'Illuminate\Http\Response<string(hey), int(401), array{}>'],
-    ['response()->noContent()', 'Illuminate\Http\Response<string(), int(204), array{}>'],
-    ['response()->json()', 'Illuminate\Http\JsonResponse<array{}, int(200), array{}>'],
-    ['response()->json(status: 329)', 'Illuminate\Http\JsonResponse<array{}, int(329), array{}>'],
-    ["response()->make('Hello')", 'Illuminate\Http\Response<string(Hello), int(200), array{}>'],
+    ['response("hey", 401)', 'Illuminate\Http\Response<string(hey), int(401), array<mixed>>'],
+    ['response()->noContent()', 'Illuminate\Http\Response<string(), int(204), array<mixed>>'],
+    ['response()->json()', 'Illuminate\Http\JsonResponse<array<mixed>, int(200), array<mixed>>'],
+    ['response()->json(status: 329)', 'Illuminate\Http\JsonResponse<array<mixed>, int(329), array<mixed>>'],
+    ["response()->make('Hello')", 'Illuminate\Http\Response<string(Hello), int(200), array<mixed>>'],
 ]);
 
 it('infers response creation', function (string $expression, string $expectedType) {
@@ -18,9 +18,9 @@ it('infers response creation', function (string $expression, string $expectedTyp
 
     expect($type->toString())->toBe($expectedType);
 })->with([
-    ["new Illuminate\Http\Response", 'Illuminate\Http\Response<string(), int(200), array{}>'],
-    ["new Illuminate\Http\Response('')", 'Illuminate\Http\Response<string(), int(200), array{}>'],
-    ["new Illuminate\Http\JsonResponse(['data' => 1])", 'Illuminate\Http\JsonResponse<array{data: int(1)}, int(200), array{}>'],
+    ["new Illuminate\Http\Response", 'Illuminate\Http\Response<string(), int(200), array<mixed>>'],
+    ["new Illuminate\Http\Response('')", 'Illuminate\Http\Response<string(), int(200), array<mixed>>'],
+    ["new Illuminate\Http\JsonResponse(['data' => 1])", 'Illuminate\Http\JsonResponse<array{data: int(1)}, int(200), array<mixed>>'],
     ["new Illuminate\Http\JsonResponse(['data' => 1], 201, ['x-foo' => 'bar'])", 'Illuminate\Http\JsonResponse<array{data: int(1)}, int(201), array{x-foo: string(bar)}>'],
 ]);
 
diff --git a/tests/TypeToSchemaTransformerTest.php b/tests/TypeToSchemaTransformerTest.php
index daf83d5..25280fe 100644
--- a/tests/TypeToSchemaTransformerTest.php
+++ b/tests/TypeToSchemaTransformerTest.php
@@ -7,6 +7,7 @@ use Dedoc\Scramble\Support\Type\ArrayItemType_;
 use Dedoc\Scramble\Support\Type\ArrayType;
 use Dedoc\Scramble\Support\Type\BooleanType;
 use Dedoc\Scramble\Support\Type\IntegerType;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
 use Dedoc\Scramble\Support\Type\Literal\LiteralStringType;
 use Dedoc\Scramble\Support\Type\NullType;
 use Dedoc\Scramble\Support\Type\ObjectType;
@@ -29,10 +30,8 @@ it('transforms simple types', function ($type, $openApiArrayed) {
     [new StringType(), ['type' => 'string']],
     [new LiteralStringType('wow'), ['type' => 'string', 'example' => 'wow']],
     [new BooleanType(), ['type' => 'boolean']],
-    [new ArrayType([
-        new ArrayItemType_(0, new StringType()),
-    ]), ['type' => 'array', 'items' => ['type' => 'string']]],
-    [new ArrayType([
+    [new ArrayType(value: new StringType), ['type' => 'array', 'items' => ['type' => 'string']]],
+    [new KeyedArrayType([
         new ArrayItemType_('key', new IntegerType()),
         new ArrayItemType_('optional_key', new IntegerType(), true),
     ]), [
@@ -43,6 +42,21 @@ it('transforms simple types', function ($type, $openApiArrayed) {
         ],
         'required' => ['key'],
     ]],
+    [new KeyedArrayType([
+        new ArrayItemType_(null, new IntegerType()),
+        new ArrayItemType_(null, new IntegerType()),
+        new ArrayItemType_(null, new IntegerType()),
+    ]), [
+        'type' => 'array',
+        'prefixItems' => [
+            ['type' => 'integer'],
+            ['type' => 'integer'],
+            ['type' => 'integer'],
+        ],
+        'minItems' => 3,
+        'maxItems' => 3,
+        'additionalItems' => false,
+    ]],
 ]);
 
 it('gets json resource type', function () {
diff --git a/tests/TypesRecognitionTest.php b/tests/TypesRecognitionTest.php
index 65bf22d..e2c1541 100644
--- a/tests/TypesRecognitionTest.php
+++ b/tests/TypesRecognitionTest.php
@@ -79,12 +79,12 @@ it('handles shape arrays', function ($phpDoc) {
 
     assertMatchesSnapshot($result ? $result->toArray() : null);
 })->with([
-    '/** @var array{string} */',
-    '/** @var array{int, string} */',
-    '/** @var array{0: string, 1: string} */',
-    '/** @var array{wow: string} */',
-    '/** @var array{test: string, wow?: string} */', // test var here is added so snapshot name generates correctly
-    '/** @var array{string, string} */',
+    '/** @var array{string} */', // list with one item
+    '/** @var array{int, string} */', // list
+    '/** @var array{0: string, 1: string} */', // list
+    '/** @var array{wow: string} */', // keyed
+    '/** @var array{test: string, wow?: string} */', // keyed, test var here is added so snapshot name generates correctly
+    '/** @var array{string, string} */', // list
 ]);
 
 it('handles intersection type', function () {
diff --git a/tests/__snapshots__/InferTypesTest__it_gets_json_resource_type__1.txt b/tests/__snapshots__/InferTypesTest__it_gets_json_resource_type__1.txt
index 6a492e5..2e7eb9e 100644
--- a/tests/__snapshots__/InferTypesTest__it_gets_json_resource_type__1.txt
+++ b/tests/__snapshots__/InferTypesTest__it_gets_json_resource_type__1.txt
@@ -1,1 +1,1 @@
-array{0: Illuminate\Http\Resources\MergeValue<boolean(true), array{foo: string(bar)}>, 1: Illuminate\Http\Resources\MergeValue<boolean, array{foo: string(bar), id_inside: int}>, when: array{0: string(wiw)}|Illuminate\Http\Resources\MissingValue, item: InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>, item_make: InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>, items: Illuminate\Http\Resources\Json\AnonymousResourceCollection<InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>>, optional_when_new: InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>|Illuminate\Http\Resources\MissingValue, 2: Illuminate\Http\Resources\MergeValue<boolean, array{threads: array{0: Illuminate\Http\Resources\MergeValue<boolean, array{brand: InferTypesTest_SampleTwoJsonResource<null>}>}}>, _test: int(1), with_doc: unknown, when_with_doc: string(wiw)|Illuminate\Http\Resources\MissingValue, some: unknown, id: int, email: string}
\ No newline at end of file
+array{0: Illuminate\Http\Resources\MergeValue<boolean(true), array{foo: string(bar)}>, 1: Illuminate\Http\Resources\MergeValue<boolean, array{foo: string(bar), id_inside: int}>, when: list{string(wiw)}|Illuminate\Http\Resources\MissingValue, item: InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>, item_make: InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>, items: Illuminate\Http\Resources\Json\AnonymousResourceCollection<InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>>, optional_when_new: InferTypesTest_SampleTwoJsonResource<\Dedoc\Scramble\Tests\Files\SampleUserModel>|Illuminate\Http\Resources\MissingValue, 2: Illuminate\Http\Resources\MergeValue<boolean, array{threads: list{Illuminate\Http\Resources\MergeValue<boolean, array{brand: InferTypesTest_SampleTwoJsonResource<null>}>}}>, _test: int(1), with_doc: unknown, when_with_doc: string(wiw)|Illuminate\Http\Resources\MissingValue, some: unknown, id: int, email: string}
\ No newline at end of file
diff --git a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_array0_string,_1_ng_)__1.yml b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_array0_string,_1_ng_)__1.yml
index 991c2d7..fe4e30b 100644
--- a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_array0_string,_1_ng_)__1.yml
+++ b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_array0_string,_1_ng_)__1.yml
@@ -1,7 +1,7 @@
-type: object
-properties:
+type: array
+prefixItems:
     - { type: string }
     - { type: string }
-required:
-    - '0'
-    - '1'
+minItems: 2
+maxItems: 2
+additionalItems: false
diff --git a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arrayint,_string_)__1.yml b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arrayint,_string_)__1.yml
index 6ea0d63..0fe5a0d 100644
--- a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arrayint,_string_)__1.yml
+++ b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arrayint,_string_)__1.yml
@@ -1,3 +1,7 @@
 type: array
-items:
-    type: string
+prefixItems:
+    - { type: integer }
+    - { type: string }
+minItems: 2
+maxItems: 2
+additionalItems: false
diff --git a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring,_string_)__1.yml b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring,_string_)__1.yml
index 24d3f79..fe4e30b 100644
--- a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring,_string_)__1.yml
+++ b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring,_string_)__1.yml
@@ -1,3 +1,7 @@
-type: object
-additionalProperties:
-    type: string
+type: array
+prefixItems:
+    - { type: string }
+    - { type: string }
+minItems: 2
+maxItems: 2
+additionalItems: false
diff --git a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring_)__1.yml b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring_)__1.yml
index 6ea0d63..dbed65e 100644
--- a/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring_)__1.yml
+++ b/tests/__snapshots__/TypesRecognitionTest__it_handles_shape_arrays_with_data_set_(_var_arraystring_)__1.yml
@@ -1,3 +1,6 @@
 type: array
-items:
-    type: string
+prefixItems:
+    - { type: string }
+minItems: 1
+maxItems: 1
+additionalItems: false