Repository: CuyZ/Valinor (php)

Issue:
Unsealed array shapes not recognized
I have a constructor like following:

```php
final readonly class LocalizedStrings
{
    /** @param array{'en-US': string, ...array<string, string>} $localized */
    public function __construct(public array $localized)
    {
    }
}
```

The rationale here is: "`en-US` must always exist, while other values are optional, but still strings".

Valinor throws this:

```
assert(!$type instanceof UnresolvableType)

vendor/cuyz/valinor/src/Mapper/Tree/Shell.php:32
vendor/cuyz/valinor/src/Mapper/Tree/Shell.php:42
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php:50
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php:23
vendor/cuyz/valinor/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php:39
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/UnionNodeBuilder.php:37
vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php:51
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php:26
vendor/cuyz/valinor/src/Mapper/Tree/Builder/StrictNodeBuilder.php:36
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ErrorCatcherNodeBuilder.php:33
vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php:16
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php:56
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php:23
vendor/cuyz/valinor/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php:39
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/UnionNodeBuilder.php:37
vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php:51
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php:26
vendor/cuyz/valinor/src/Mapper/Tree/Builder/StrictNodeBuilder.php:36
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ErrorCatcherNodeBuilder.php:33
vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php:16
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ListNodeBuilder.php:58
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ListNodeBuilder.php:37
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/UnionNodeBuilder.php:37
vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php:37
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php:26
vendor/cuyz/valinor/src/Mapper/Tree/Builder/StrictNodeBuilder.php:36
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ErrorCatcherNodeBuilder.php:33
vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php:16
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php:56
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php:23
vendor/cuyz/valinor/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php:39
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/UnionNodeBuilder.php:37
vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php:51
vendor/cuyz/valinor/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php:24
vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php:26
vendor/cuyz/valinor/src/Mapper/Tree/Builder/StrictNodeBuilder.php:36
vendor/cuyz/valinor/src/Mapper/Tree/Builder/ErrorCatcherNodeBuilder.php:33
vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php:16
vendor/cuyz/valinor/src/Mapper/TypeTreeMapper.php:44
vendor/cuyz/valinor/src/Mapper/TypeTreeMapper.php:25
```

The assertion trips on this type:

```
/var/www/wefox/vendor/cuyz/valinor/src/Mapper/Tree/Shell.php:31:
class CuyZ\Valinor\Type\Types\UnresolvableType#2174 (2) {
  private string $rawType =>
  string(58) "array{'en-US': string, ...array<non-empty-string, string>}"
  private string $message =>
  string(267) "The type `array{'en-US': string, ...array<non-empty-string, string>}` for parameter `LocalizedString::__construct($localized)` could not be resolved: Comma missing in shaped array signature `array{'en-US': string, 0: ...array`."
}
```

Pull request description:
feat: introduce unsealed shaped array syntax
This syntax enables an extension of the shaped array type by allowing additional values that must respect a certain type.

```php
$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper();

// Default syntax can be used like this:
$mapper->map(
    'array{foo: string, ...array<string>}',
    [
        'foo' => 'foo',
        'bar' => 'bar', // ✅ valid additional value
    ]
);

$mapper->map(
    'array{foo: string, ...array<string>}',
    [
        'foo' => 'foo',
        'bar' => 1337, // ❌ invalid value 1337
    ]
);

// Key type can be added as well:
$mapper->map(
    'array{foo: string, ...array<int, string>}',
    [
        'foo' => 'foo',
        42 => 'bar', // ✅ valid additional key
    ]
);

$mapper->map(
    'array{foo: string, ...array<int, string>}',
    [
        'foo' => 'foo',
        'bar' => 'bar' // ❌ invalid key
    ]
);

// Advanced types can be used:
$mapper->map(
    "array{
        'en_US': non-empty-string,
        ...array<non-empty-string, non-empty-string>
    }",
    [
        'en_US' => 'Hello',
        'fr_FR' => 'Salut', // ✅ valid additional value
    ]
);

$mapper->map(
    "array{
        'en_US': non-empty-string,
        ...array<non-empty-string, non-empty-string>
    }",
    [
        'en_US' => 'Hello',
        'fr_FR' => '', // ❌ invalid value
    ]
);

// If the permissive type is enabled, the following will work:
(new \CuyZ\Valinor\MapperBuilder())
    ->allowPermissiveTypes()
    ->mapper()
    ->map(
        'array{foo: string, ...}',
        ['foo' => 'foo', 'bar' => 'bar', 42 => 1337]
    ); // ✅
```

Fixes #438

Patch:
diff --git a/docs/pages/usage/type-reference.md b/docs/pages/usage/type-reference.md
index 5a6752e..2fb54fe 100644
--- a/docs/pages/usage/type-reference.md
+++ b/docs/pages/usage/type-reference.md
@@ -134,6 +134,15 @@ final class SomeClass
 
         /** @var array{string, bar: int} */
         private array $shapedArrayWithUndefinedKey,
+
+        /** @var array{foo: string, ...} */
+        private array $unsealedShapedArray,
+        
+        /** @var array{foo: string, ...array<string>} */
+        private array $unsealedShapedArrayWithExplicitType,
+        
+        /** @var array{foo: string, ...array<int, string>} */
+        private array $unsealedShapedArrayWithExplicitKeyAndType,
     ) {}
 }
 ```
diff --git a/qa/PHPStan/Extension/TreeMapperPHPStanExtension.php b/qa/PHPStan/Extension/TreeMapperPHPStanExtension.php
index ac447d9..2b2f734 100644
--- a/qa/PHPStan/Extension/TreeMapperPHPStanExtension.php
+++ b/qa/PHPStan/Extension/TreeMapperPHPStanExtension.php
@@ -8,6 +8,7 @@ use CuyZ\Valinor\Mapper\TreeMapper;
 use PhpParser\Node\Expr\MethodCall;
 use PHPStan\Analyser\Scope;
 use PHPStan\PhpDoc\TypeStringResolver;
+use PHPStan\PhpDocParser\Parser\ParserException;
 use PHPStan\Reflection\MethodReflection;
 use PHPStan\Type\ClassStringType;
 use PHPStan\Type\Constant\ConstantStringType;
@@ -46,7 +47,15 @@ final class TreeMapperPHPStanExtension implements DynamicMethodReturnTypeExtensi
             return $type->traverse(fn (Type $type) => $this->type($type));
         }
 
-        return $this->type($type);
+        try {
+            return $this->type($type);
+        } catch (ParserException) {
+            // Fallback to `mixed` type if the type cannot be resolved. This can
+            // occur with a type that is not understood/supported by PHPStan. If
+            // that happens, returning a mixed type is the safest option, as it
+            // will not make the analysis fail.
+            return new MixedType();
+        }
     }
 
     private function type(Type $type): Type
diff --git a/src/Definition/Repository/Cache/Compiler/TypeCompiler.php b/src/Definition/Repository/Cache/Compiler/TypeCompiler.php
index b633a30..adebebd 100644
--- a/src/Definition/Repository/Cache/Compiler/TypeCompiler.php
+++ b/src/Definition/Repository/Cache/Compiler/TypeCompiler.php
@@ -97,13 +97,20 @@ final class TypeCompiler
                     default => "$class::default()",
                 };
             case $type instanceof ShapedArrayType:
-                $shapes = array_map(
+                $elements = implode(', ', array_map(
                     fn (ShapedArrayElement $element) => $this->compileArrayShapeElement($element),
                     $type->elements()
-                );
-                $shapes = implode(', ', $shapes);
+                ));
+
+                if ($type->hasUnsealedType()) {
+                    $unsealedType = $this->compile($type->unsealedType());
+
+                    return "$class::unsealed($unsealedType, $elements)";
+                } elseif ($type->isUnsealed()) {
+                    return "$class::unsealedWithoutType($elements)";
+                }
 
-                return "new $class(...[$shapes])";
+                return "new $class($elements)";
             case $type instanceof ArrayType:
             case $type instanceof NonEmptyArrayType:
                 if ($type->toString() === 'array' || $type->toString() === 'non-empty-array') {
diff --git a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php
index 492d74a..6dc633f 100644
--- a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php
@@ -69,6 +69,15 @@ final class ShapedArrayNodeBuilder implements NodeBuilder
             unset($value[$key]);
         }
 
+        if ($type->isUnsealed()) {
+            $unsealedShell = $shell->withType($type->unsealedType())->withValue($value);
+            $unsealedChildren = $rootBuilder->build($unsealedShell)->children();
+
+            foreach ($unsealedChildren as $unsealedChild) {
+                $children[$unsealedChild->name()] = $unsealedChild;
+            }
+        }
+
         return $children;
     }
 
diff --git a/src/Type/Parser/Exception/Iterable/ShapedArrayClosingBracketMissing.php b/src/Type/Parser/Exception/Iterable/ShapedArrayClosingBracketMissing.php
index b6bf3bc..9ee0385 100644
--- a/src/Type/Parser/Exception/Iterable/ShapedArrayClosingBracketMissing.php
+++ b/src/Type/Parser/Exception/Iterable/ShapedArrayClosingBracketMissing.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;
 
 use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
+use CuyZ\Valinor\Type\Type;
 use CuyZ\Valinor\Type\Types\ShapedArrayElement;
 use RuntimeException;
 
@@ -16,10 +17,16 @@ final class ShapedArrayClosingBracketMissing extends RuntimeException implements
     /**
      * @param ShapedArrayElement[] $elements
      */
-    public function __construct(array $elements)
+    public function __construct(array $elements, Type|null|false $unsealedType = null)
     {
         $signature = 'array{' . implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements));
 
+        if ($unsealedType === false) {
+            $signature .= ', ...';
+        } elseif ($unsealedType instanceof Type) {
+            $signature .= ', ...' . $unsealedType->toString();
+        }
+
         parent::__construct(
             "Missing closing curly bracket in shaped array signature `$signature`.",
             1631283658
diff --git a/src/Type/Parser/Exception/Iterable/ShapedArrayInvalidUnsealedType.php b/src/Type/Parser/Exception/Iterable/ShapedArrayInvalidUnsealedType.php
new file mode 100644
index 0000000..cef92e2
--- /dev/null
+++ b/src/Type/Parser/Exception/Iterable/ShapedArrayInvalidUnsealedType.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;
+
+use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
+use CuyZ\Valinor\Type\Type;
+use CuyZ\Valinor\Type\Types\ShapedArrayElement;
+use RuntimeException;
+
+use function implode;
+
+/** @internal */
+final class ShapedArrayInvalidUnsealedType extends RuntimeException implements InvalidType
+{
+    /**
+     * @param ShapedArrayElement[] $elements
+     */
+    public function __construct(array $elements, Type $unsealedType)
+    {
+        $signature = 'array{';
+        $signature .= implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements));
+        $signature .= ', ...' . $unsealedType->toString();
+        $signature .= '}';
+
+        parent::__construct(
+            "Invalid unsealed type `{$unsealedType->toString()}` in shaped array signature `$signature`, it should be a valid array.",
+            1711618899,
+        );
+    }
+}
diff --git a/src/Type/Parser/Exception/Iterable/ShapedArrayUnexpectedTokenAfterSealedType.php b/src/Type/Parser/Exception/Iterable/ShapedArrayUnexpectedTokenAfterSealedType.php
new file mode 100644
index 0000000..15be028
--- /dev/null
+++ b/src/Type/Parser/Exception/Iterable/ShapedArrayUnexpectedTokenAfterSealedType.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;
+
+use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
+use CuyZ\Valinor\Type\Parser\Lexer\Token\Token;
+use CuyZ\Valinor\Type\Type;
+use CuyZ\Valinor\Type\Types\ShapedArrayElement;
+use RuntimeException;
+
+use function implode;
+
+/** @internal */
+final class ShapedArrayUnexpectedTokenAfterSealedType extends RuntimeException implements InvalidType
+{
+    /**
+     * @param array<ShapedArrayElement> $elements
+     * @param list<Token> $unexpectedTokens
+     */
+    public function __construct(array $elements, Type $unsealedType, array $unexpectedTokens)
+    {
+        $unexpected = implode('', array_map(fn (Token $token) => $token->symbol(), $unexpectedTokens));
+
+        $signature = 'array{';
+        $signature .= implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements));
+        $signature .= ', ...' . $unsealedType->toString();
+        $signature .= $unexpected;
+
+        parent::__construct(
+            "Unexpected `$unexpected` after sealed type in shaped array signature `$signature`, expected a `}`.",
+            1711618958,
+        );
+    }
+}
diff --git a/src/Type/Parser/Exception/Iterable/ShapedArrayWithoutElementsWithSealedType.php b/src/Type/Parser/Exception/Iterable/ShapedArrayWithoutElementsWithSealedType.php
new file mode 100644
index 0000000..578f00c
--- /dev/null
+++ b/src/Type/Parser/Exception/Iterable/ShapedArrayWithoutElementsWithSealedType.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;
+
+use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
+use CuyZ\Valinor\Type\Type;
+use RuntimeException;
+
+/** @internal */
+final class ShapedArrayWithoutElementsWithSealedType extends RuntimeException implements InvalidType
+{
+    public function __construct(Type $unsealedType)
+    {
+        $signature = "array{...{$unsealedType->toString()}}";
+
+        parent::__construct(
+            "Missing elements in shaped array signature `$signature`.",
+            1711629845,
+        );
+    }
+}
diff --git a/src/Type/Parser/Lexer/NativeLexer.php b/src/Type/Parser/Lexer/NativeLexer.php
index 3f8340a..5040df1 100644
--- a/src/Type/Parser/Lexer/NativeLexer.php
+++ b/src/Type/Parser/Lexer/NativeLexer.php
@@ -26,6 +26,7 @@ use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningCurlyBracketToken;
 use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningSquareBracketToken;
 use CuyZ\Valinor\Type\Parser\Lexer\Token\QuoteToken;
 use CuyZ\Valinor\Type\Parser\Lexer\Token\Token;
+use CuyZ\Valinor\Type\Parser\Lexer\Token\TripleDotsToken;
 use CuyZ\Valinor\Type\Parser\Lexer\Token\UnionToken;
 
 use function filter_var;
@@ -56,6 +57,7 @@ final class NativeLexer implements TypeLexer
             ':' => ColonToken::get(),
             '?' => NullableToken::get(),
             ',' => CommaToken::get(),
+            '...' => TripleDotsToken::get(),
             '"', "'" => new QuoteToken($symbol),
             'int', 'integer' => IntegerToken::get(),
             'array' => ArrayToken::array(),
diff --git a/src/Type/Parser/Lexer/Token/ArrayToken.php b/src/Type/Parser/Lexer/Token/ArrayToken.php
index 8d1563f..4bd8846 100644
--- a/src/Type/Parser/Lexer/Token/ArrayToken.php
+++ b/src/Type/Parser/Lexer/Token/ArrayToken.php
@@ -12,6 +12,9 @@ use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayColonTokenMissing;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayCommaMissing;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementTypeMissing;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayEmptyElements;
+use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayInvalidUnsealedType;
+use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayUnexpectedTokenAfterSealedType;
+use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayWithoutElementsWithSealedType;
 use CuyZ\Valinor\Type\Parser\Lexer\TokenStream;
 use CuyZ\Valinor\Type\Type;
 use CuyZ\Valinor\Type\Types\ArrayKeyType;
@@ -99,6 +102,8 @@ final class ArrayToken implements TraversingToken
 
         $elements = [];
         $index = 0;
+        $isUnsealed = false;
+        $unsealedType = null;
 
         while (! $stream->done()) {
             if ($stream->next() instanceof ClosingCurlyBracketToken) {
@@ -121,12 +126,50 @@ final class ArrayToken implements TraversingToken
 
             $optional = false;
 
+            if ($stream->next() instanceof TripleDotsToken) {
+                $isUnsealed = true;
+                $stream->forward();
+            }
+
+            if ($stream->done()) {
+                throw new ShapedArrayClosingBracketMissing($elements, unsealedType: false);
+            }
+
             if ($stream->next() instanceof UnknownSymbolToken) {
                 $type = new StringValueType($stream->forward()->symbol());
+            } elseif ($isUnsealed && $stream->next() instanceof ClosingCurlyBracketToken) {
+                $stream->forward();
+                break;
             } else {
                 $type = $stream->read();
             }
 
+            if ($isUnsealed) {
+                $unsealedType = $type;
+
+                if ($elements === []) {
+                    throw new ShapedArrayWithoutElementsWithSealedType($unsealedType);
+                }
+
+                if (! $unsealedType instanceof ArrayType) {
+                    throw new ShapedArrayInvalidUnsealedType($elements, $unsealedType);
+                }
+
+                if ($stream->done()) {
+                    throw new ShapedArrayClosingBracketMissing($elements, $unsealedType);
+                } elseif (! $stream->next() instanceof ClosingCurlyBracketToken) {
+                    $unexpected = [];
+
+                    while (! $stream->done() && ! $stream->next() instanceof ClosingCurlyBracketToken) {
+                        $unexpected[] = $stream->forward();
+                    }
+
+                    throw new ShapedArrayUnexpectedTokenAfterSealedType($elements, $unsealedType, $unexpected);
+                }
+
+                continue;
+            }
+
             if ($stream->done()) {
                 $elements[] = new ShapedArrayElement(new IntegerValueType($index), $type);
 
@@ -178,10 +221,16 @@ final class ArrayToken implements TraversingToken
             }
         }
 
-        if (empty($elements)) {
+        if ($elements === []) {
             throw new ShapedArrayEmptyElements();
         }
 
+        if ($unsealedType) {
+            return ShapedArrayType::unsealed($unsealedType, ...$elements);
+        } elseif ($isUnsealed) {
+            return ShapedArrayType::unsealedWithoutType(...$elements);
+        }
+
         return new ShapedArrayType(...$elements);
     }
 }
diff --git a/src/Type/Parser/Lexer/Token/TripleDotsToken.php b/src/Type/Parser/Lexer/Token/TripleDotsToken.php
new file mode 100644
index 0000000..a800bba
--- /dev/null
+++ b/src/Type/Parser/Lexer/Token/TripleDotsToken.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Type\Parser\Lexer\Token;
+
+use CuyZ\Valinor\Utility\IsSingleton;
+
+/** @internal */
+final class TripleDotsToken implements Token
+{
+    use IsSingleton;
+
+    public function symbol(): string
+    {
+        return '...';
+    }
+}
diff --git a/src/Type/Types/ArrayKeyType.php b/src/Type/Types/ArrayKeyType.php
index 3adc944..931f062 100644
--- a/src/Type/Types/ArrayKeyType.php
+++ b/src/Type/Types/ArrayKeyType.php
@@ -66,7 +66,7 @@ final class ArrayKeyType implements ScalarType
         return self::$string ??= new self(NativeStringType::get());
     }
 
-    public static function from(Type $type): ?self
+    public static function from(Type $type): self
     {
         return match (true) {
             $type instanceof self => $type,
diff --git a/src/Type/Types/ShapedArrayType.php b/src/Type/Types/ShapedArrayType.php
index 4238cdb..e94cd09 100644
--- a/src/Type/Types/ShapedArrayType.php
+++ b/src/Type/Types/ShapedArrayType.php
@@ -9,10 +9,9 @@ use CuyZ\Valinor\Type\CompositeType;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementDuplicatedKey;
 use CuyZ\Valinor\Type\Type;
 
-use function array_diff;
 use function array_key_exists;
-use function array_keys;
 use function array_map;
+use function assert;
 use function count;
 use function implode;
 use function in_array;
@@ -24,15 +23,13 @@ final class ShapedArrayType implements CompositeType
     /** @var ShapedArrayElement[] */
     private array $elements;
 
-    private string $signature;
+    private bool $isUnsealed = false;
+
+    private ?ArrayType $unsealedType = null;
 
     public function __construct(ShapedArrayElement ...$elements)
     {
         $this->elements = $elements;
-        $this->signature =
-            'array{' .
-            implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements))
-            . '}';
 
         $keys = [];
 
@@ -40,24 +37,56 @@ final class ShapedArrayType implements CompositeType
             $key = $element->key()->value();
 
             if (in_array($key, $keys, true)) {
-                throw new ShapedArrayElementDuplicatedKey((string)$key, $this->signature);
+                throw new ShapedArrayElementDuplicatedKey((string)$key, $this->toString());
             }
 
             $keys[] = $key;
         }
     }
 
+    public static function unsealed(ArrayType $unsealedType, ShapedArrayElement ...$elements): self
+    {
+        $self = new self(...$elements);
+        $self->isUnsealed = true;
+        $self->unsealedType = $unsealedType;
+
+        return $self;
+    }
+
+    public static function unsealedWithoutType(ShapedArrayElement ...$elements): self
+    {
+        $self = new self(...$elements);
+        $self->isUnsealed = true;
+
+        return $self;
+    }
+
+    public function isUnsealed(): bool
+    {
+        return $this->isUnsealed;
+    }
+
+    public function hasUnsealedType(): bool
+    {
+        return $this->unsealedType !== null;
+    }
+
+    public function unsealedType(): ArrayType
+    {
+        assert($this->isUnsealed);
+
+        return $this->unsealedType ?? ArrayType::native();
+    }
+
     public function accepts(mixed $value): bool
     {
         if (! is_array($value)) {
             return false;
         }
 
-        $keys = [];
-
         foreach ($this->elements as $shape) {
             $type = $shape->type();
-            $keys[] = $key = $shape->key()->value();
+            $key = $shape->key()->value();
             $valueExists = array_key_exists($key, $value);
 
             if (! $valueExists && ! $shape->isOptional()) {
@@ -67,11 +96,15 @@ final class ShapedArrayType implements CompositeType
             if ($valueExists && ! $type->accepts($value[$key])) {
                 return false;
             }
+
+            unset($value[$key]);
         }
 
-        $excess = array_diff(array_keys($value), $keys);
+        if ($this->isUnsealed) {
+            return $this->unsealedType()->accepts($value);
+        }
 
-        return count($excess) === 0;
+        return count($value) === 0;
     }
 
     public function matches(Type $other): bool
@@ -98,6 +131,10 @@ final class ShapedArrayType implements CompositeType
                 }
             }
 
+            if ($this->isUnsealed && ! $this->unsealedType()->matches($other)) {
+                return false;
+            }
+
             return true;
         }
 
@@ -107,11 +144,9 @@ final class ShapedArrayType implements CompositeType
 
         foreach ($this->elements as $element) {
             foreach ($other->elements as $otherElement) {
-                if ($element->key()->matches($otherElement->key())) {
-                    if (! $element->type()->matches($otherElement->type())) {
-                        return false;
-                    }
-
+                if ($element->key()->matches($otherElement->key())
+                    && $element->type()->matches($otherElement->type())
+                ) {
                     continue 2;
                 }
             }
@@ -121,6 +156,11 @@ final class ShapedArrayType implements CompositeType
             }
         }
 
+        if ($other->isUnsealed) {
+            return $this->isUnsealed
+                && $this->unsealedType()->matches($other->unsealedType());
+        }
+
         return true;
     }
 
@@ -136,6 +176,10 @@ final class ShapedArrayType implements CompositeType
             }
         }
 
+        if ($this->isUnsealed) {
+            $types = [...$types, $this->unsealedType(), ...$this->unsealedType()->traverse()];
+        }
+
         return $types;
     }
 
@@ -149,6 +193,19 @@ final class ShapedArrayType implements CompositeType
 
     public function toString(): string
     {
-        return $this->signature;
+        $signature = 'array{';
+        $signature .= implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $this->elements));
+
+        if ($this->isUnsealed) {
+            $signature .= ', ...';
+
+            if ($this->unsealedType) {
+                $signature .= $this->unsealedType->toString();
+            }
+        }
+
+        $signature .= '}';
+
+        return $signature;
     }
 }


Test patch:
diff --git a/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php b/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php
index 645a0d2..dd32ff2 100644
--- a/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php
+++ b/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php
@@ -124,6 +124,15 @@ final class TypeCompilerTest extends TestCase
             new ShapedArrayElement(new StringValueType('foo'), NativeStringType::get()),
             new ShapedArrayElement(new IntegerValueType(1337), NativeIntegerType::get(), true)
         )];
+        yield [ShapedArrayType::unsealedWithoutType(
+            new ShapedArrayElement(new StringValueType('foo'), NativeStringType::get()),
+            new ShapedArrayElement(new IntegerValueType(1337), NativeIntegerType::get(), true)
+        )];
+        yield [ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::default(), NativeFloatType::get()),
+            new ShapedArrayElement(new StringValueType('foo'), NativeStringType::get()),
+            new ShapedArrayElement(new IntegerValueType(1337), NativeIntegerType::get(), true)
+        )];
         yield [new IterableType(ArrayKeyType::default(), NativeFloatType::get())];
         yield [new IterableType(ArrayKeyType::integer(), NativeIntegerType::get())];
         yield [new IterableType(ArrayKeyType::string(), NativeStringType::get())];
diff --git a/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php b/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php
index cda6bdd..8f2a41c 100644
--- a/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php
+++ b/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php
@@ -29,6 +29,9 @@ use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayColonTokenMissing;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayCommaMissing;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementTypeMissing;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayEmptyElements;
+use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayInvalidUnsealedType;
+use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayUnexpectedTokenAfterSealedType;
+use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayWithoutElementsWithSealedType;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\SimpleArrayClosingBracketMissing;
 use CuyZ\Valinor\Type\Parser\Exception\MissingClosingQuoteChar;
 use CuyZ\Valinor\Type\Parser\Exception\RightIntersectionTypeMissing;
@@ -1288,6 +1291,51 @@ final class NativeLexerTest extends TestCase
         $this->parser->parse('array{int, string]');
     }
 
+    public function test_unsealed_shaped_array_with_missing_closing_bracket_throws_exception(): void
+    {
+        $this->expectException(ShapedArrayClosingBracketMissing::class);
+        $this->expectExceptionCode(1631283658);
+        $this->expectExceptionMessage('Missing closing curly bracket in shaped array signature `array{0: int, ...`.');
+
+        $this->parser->parse('array{int, ...');
+    }
+
+    public function test_shaped_array_with_unsealed_type_with_missing_closing_bracket_throws_exception(): void
+    {
+        $this->expectException(ShapedArrayClosingBracketMissing::class);
+        $this->expectExceptionCode(1631283658);
+        $this->expectExceptionMessage('Missing closing curly bracket in shaped array signature `array{0: int, ...array`.');
+
+        $this->parser->parse('array{int, ...array');
+    }
+
+    public function test_shaped_array_with_invalid_unsealed_type_throws_exception(): void
+    {
+        $this->expectException(ShapedArrayInvalidUnsealedType::class);
+        $this->expectExceptionCode(1711618899);
+        $this->expectExceptionMessage('Invalid unsealed type `string` in shaped array signature `array{0: int, ...string}`, it should be a valid array.');
+
+        $this->parser->parse('array{int, ...string}');
+    }
+
+    public function test_shaped_array_with_unsealed_type_followed_by_unexpected_token_throws_exception(): void
+    {
+        $this->expectException(ShapedArrayUnexpectedTokenAfterSealedType::class);
+        $this->expectExceptionCode(1711618958);
+        $this->expectExceptionMessage('Unexpected `int|string` after sealed type in shaped array signature `array{0: int, ...array<string>int|string`, expected a `}`.');
+
+        $this->parser->parse('array{int, ...array<string>int|string}');
+    }
+
+    public function test_unsealed_shaped_array_without_elements_throws_exception(): void
+    {
+        $this->expectException(ShapedArrayWithoutElementsWithSealedType::class);
+        $this->expectExceptionCode(1711629845);
+        $this->expectExceptionMessage('Missing elements in shaped array signature `array{...array<string>}`.');
+
+        $this->parser->parse('array{...array<string>}');
+    }
+
     public function test_missing_min_value_for_integer_range_throws_exception(): void
     {
         $this->expectException(IntegerRangeMissingMinValue::class);
diff --git a/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php b/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php
index acf2634..8eb5088 100644
--- a/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php
+++ b/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php
@@ -61,6 +61,19 @@ final class ShapedArrayValuesMappingTest extends IntegrationTestCase
             'shapedArrayWithLowercaseEnumNameAsKey' => [
                 'enumatrootnamespace' => 'foo',
             ],
+            'unsealedShapedArrayWithoutKeyWithStringType' => [
+                'foo' => 'foo',
+                'bar' => 'bar',
+                42 => 'baz',
+            ],
+            'unsealedShapedArrayWithIntegerKeyWithStringType' => [
+                'foo' => 'foo',
+                42 => 'bar',
+            ],
+            'unsealedShapedArrayWithStringKeyWithStringType' => [
+                'foo' => 'foo',
+                'bar' => 'bar',
+            ],
         ];
 
         foreach ([ShapedArrayValues::class, ShapedArrayValuesWithConstructor::class] as $class) {
@@ -85,6 +98,9 @@ final class ShapedArrayValuesMappingTest extends IntegrationTestCase
             self::assertSame('foo', $result->shapedArrayWithLowercaseClassNameAsKey['stdclass']);
             self::assertSame('foo', $result->shapedArrayWithEnumNameAsKey['EnumAtRootNamespace']);
             self::assertSame('foo', $result->shapedArrayWithLowercaseEnumNameAsKey['enumatrootnamespace']);
+            self::assertSame($source['unsealedShapedArrayWithoutKeyWithStringType'], $result->unsealedShapedArrayWithoutKeyWithStringType);
+            self::assertSame($source['unsealedShapedArrayWithIntegerKeyWithStringType'], $result->unsealedShapedArrayWithIntegerKeyWithStringType);
+            self::assertSame($source['unsealedShapedArrayWithStringKeyWithStringType'], $result->unsealedShapedArrayWithStringKeyWithStringType);
         }
     }
 
@@ -103,6 +119,40 @@ final class ShapedArrayValuesMappingTest extends IntegrationTestCase
             self::assertSame('Value object(stdClass) is not a valid string.', (string)$error);
         }
     }
+
+    public function test_unsealed_shaped_array_invalid_key_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()->mapper()->map(
+                'array{foo: string, ...array<int, string>}',
+                [
+                    'foo' => new stdClass(),
+                    'bar' => 'bar',
+                ],
+            );
+        } catch (MappingError $exception) {
+            $error = $exception->node()->children()['bar']->messages()[0];
+
+            self::assertSame("Key 'bar' does not match type `int`.", (string)$error);
+        }
+    }
+
+    public function test_unsealed_shaped_array_invalid_value_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()->mapper()->map(
+                'array{foo: string, ...array<int>}',
+                [
+                    'foo' => new stdClass(),
+                    'bar' => 'bar',
+                ],
+            );
+        } catch (MappingError $exception) {
+            $error = $exception->node()->children()['bar']->messages()[0];
+
+            self::assertSame("Value 'bar' is not a valid integer.", (string)$error);
+        }
+    }
 }
 
 class ShapedArrayValues
@@ -155,6 +205,15 @@ class ShapedArrayValues
 
     /** @var array{enumatrootnamespace: string} */
     public array $shapedArrayWithLowercaseEnumNameAsKey;
+
+    /** @var array{foo: string, ...array<string>} */
+    public array $unsealedShapedArrayWithoutKeyWithStringType; // @phpstan-ignore-line / PHPStan does not (yet) understand the unsealed shaped array syntax
+
+    /** @var array{foo: string, ...array<int, string>} */
+    public array $unsealedShapedArrayWithIntegerKeyWithStringType; // @phpstan-ignore-line / PHPStan does not (yet) understand the unsealed shaped array syntax
+
+    /** @var array{foo: string, ...array<string, string>} */
+    public array $unsealedShapedArrayWithStringKeyWithStringType; // @phpstan-ignore-line / PHPStan does not (yet) understand the unsealed shaped array syntax
 }
 
 class ShapedArrayValuesWithConstructor extends ShapedArrayValues
@@ -179,7 +238,11 @@ class ShapedArrayValuesWithConstructor extends ShapedArrayValues
      * @param array{stdclass: string} $shapedArrayWithLowercaseClassNameAsKey
      * @param array{EnumAtRootNamespace: string} $shapedArrayWithEnumNameAsKey
      * @param array{enumatrootnamespace: string} $shapedArrayWithLowercaseEnumNameAsKey
+     * @param array{foo: string, ...array<string>} $unsealedShapedArrayWithoutKeyWithStringType
+     * @param array{foo: string, ...array<int, string>} $unsealedShapedArrayWithIntegerKeyWithStringType
+     * @param array{foo: string, ...array<string, string>} $unsealedShapedArrayWithStringKeyWithStringType
      */
+    // @phpstan-ignore-next-line / PHPStan does not (yet) understand the unsealed shaped array syntax
     public function __construct(
         array $basicShapedArrayWithStringKeys,
         array $basicShapedArrayWithSingleQuotedStringKeys,
@@ -194,6 +257,9 @@ class ShapedArrayValuesWithConstructor extends ShapedArrayValues
         array $shapedArrayWithLowercaseClassNameAsKey,
         array $shapedArrayWithEnumNameAsKey,
         array $shapedArrayWithLowercaseEnumNameAsKey,
+        array $unsealedShapedArrayWithoutKeyWithStringType,
+        array $unsealedShapedArrayWithIntegerKeyWithStringType,
+        array $unsealedShapedArrayWithStringKeyWithStringType,
     ) {
         $this->basicShapedArrayWithStringKeys = $basicShapedArrayWithStringKeys;
         $this->basicShapedArrayWithSingleQuotedStringKeys = $basicShapedArrayWithSingleQuotedStringKeys;
@@ -208,5 +274,8 @@ class ShapedArrayValuesWithConstructor extends ShapedArrayValues
         $this->shapedArrayWithLowercaseClassNameAsKey = $shapedArrayWithLowercaseClassNameAsKey;
         $this->shapedArrayWithEnumNameAsKey = $shapedArrayWithEnumNameAsKey;
         $this->shapedArrayWithLowercaseEnumNameAsKey = $shapedArrayWithLowercaseEnumNameAsKey;
+        $this->unsealedShapedArrayWithoutKeyWithStringType = $unsealedShapedArrayWithoutKeyWithStringType;
+        $this->unsealedShapedArrayWithIntegerKeyWithStringType = $unsealedShapedArrayWithIntegerKeyWithStringType;
+        $this->unsealedShapedArrayWithStringKeyWithStringType = $unsealedShapedArrayWithStringKeyWithStringType;
     }
 }
diff --git a/tests/Integration/Mapping/Other/PermissiveTypesMappingTest.php b/tests/Integration/Mapping/Other/PermissiveTypesMappingTest.php
index 89d50f0..a88e026 100644
--- a/tests/Integration/Mapping/Other/PermissiveTypesMappingTest.php
+++ b/tests/Integration/Mapping/Other/PermissiveTypesMappingTest.php
@@ -47,4 +47,30 @@ final class PermissiveTypesMappingTest extends IntegrationTestCase
 
         self::assertSame($source, $result);
     }
+
+    public function test_can_map_to_unsealed_shaped_array_without_type(): void
+    {
+        $source = ['foo' => 'foo', 'bar' => 'bar', 42 => 42];
+
+        try {
+            $result = $this->mapper->map('array{foo: string, ...}', $source);
+        } catch (MappingError $error) {
+            $this->mappingFail($error);
+        }
+
+        self::assertSame($source, $result); // @phpstan-ignore-line / PHPStan does not (yet) understand the unsealed shaped array syntax
+    }
+
+    public function test_can_map_to_unsealed_shaped_array_without_type_or_string(): void
+    {
+        $source = 'foo';
+
+        try {
+            $result = $this->mapper->map('array{foo: string, ...}|string', $source);
+        } catch (MappingError $error) {
+            $this->mappingFail($error);
+        }
+
+        self::assertSame($source, $result);
+    }
 }
diff --git a/tests/Integration/Mapping/Other/StrictMappingTest.php b/tests/Integration/Mapping/Other/StrictMappingTest.php
index fcf33c8..6933244 100644
--- a/tests/Integration/Mapping/Other/StrictMappingTest.php
+++ b/tests/Integration/Mapping/Other/StrictMappingTest.php
@@ -53,7 +53,7 @@ final class StrictMappingTest extends IntegrationTestCase
         $this->mapperBuilder()->mapper()->map(ObjectContainingUndefinedObjectType::class, ['value' => new stdClass()]);
     }
 
-    public function test_map_to_type_containing_mixed_type_throws_exception(): void
+    public function test_map_to_shaped_array_containing_mixed_type_throws_exception(): void
     {
         $this->expectException(PermissiveTypeFound::class);
         $this->expectExceptionCode(1655231817);
@@ -62,6 +62,15 @@ final class StrictMappingTest extends IntegrationTestCase
         $this->mapperBuilder()->mapper()->map('array{foo: string, bar: mixed}', ['foo' => 'foo', 'bar' => 42]);
     }
 
+    public function test_map_to_unsealed_shaped_array_without_type_throws_exception(): void
+    {
+        $this->expectException(PermissiveTypeFound::class);
+        $this->expectExceptionCode(1655231817);
+        $this->expectExceptionMessage('Type `mixed` in `array{foo: string, ...}` is too permissive.');
+
+        $this->mapperBuilder()->mapper()->map('array{foo: string, ...}', ['foo' => 'foo', 'bar' => 42]);
+    }
+
     public function test_map_to_object_containing_mixed_type_throws_exception(): void
     {
         $this->expectException(PermissiveTypeNotAllowed::class);
diff --git a/tests/Integration/Mapping/UnionMappingTest.php b/tests/Integration/Mapping/UnionMappingTest.php
index 6465f90..846283f 100644
--- a/tests/Integration/Mapping/UnionMappingTest.php
+++ b/tests/Integration/Mapping/UnionMappingTest.php
@@ -106,6 +106,18 @@ final class UnionMappingTest extends IntegrationTestCase
             'assertion' => fn (mixed $result) => self::assertSame(['status' => 400, 'error' => 'foo'], $result),
         ];
 
+        yield 'unsealed shaped array of non-empty-string or string with array of strings' => [
+            'type' => "array{'en_US': non-empty-string, ...array<non-empty-string, non-empty-string>} | string",
+            'source' => ['en_US' => 'Hello', 'fr_FR' => 'Salut'],
+            'assertion' => fn (mixed $result) => self::assertSame(['en_US' => 'Hello', 'fr_FR' => 'Salut'], $result),
+        ];
+
+        yield 'unsealed shaped array of non-empty-string or string with string' => [
+            'type' => "array{'en_US': non-empty-string, ...array<non-empty-string, non-empty-string>} | string",
+            'source' => 'Hello',
+            'assertion' => fn (mixed $result) => self::assertSame('Hello', $result),
+        ];
+
         yield 'array of string or object with one string value, with array of string' => [
             'type' => 'array<string>|' . SomeObjectWithOneStringValue::class,
             'source' => ['foo', 'bar'],
diff --git a/tests/Unit/Type/Types/ShapedArrayTypeTest.php b/tests/Unit/Type/Types/ShapedArrayTypeTest.php
index 64a30e2..d5d42b9 100644
--- a/tests/Unit/Type/Types/ShapedArrayTypeTest.php
+++ b/tests/Unit/Type/Types/ShapedArrayTypeTest.php
@@ -9,11 +9,12 @@ use CuyZ\Valinor\Tests\Fake\Type\FakeType;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementDuplicatedKey;
 use CuyZ\Valinor\Type\Types\ArrayKeyType;
 use CuyZ\Valinor\Type\Types\ArrayType;
-use CuyZ\Valinor\Type\Types\NativeFloatType;
 use CuyZ\Valinor\Type\Types\IntegerValueType;
 use CuyZ\Valinor\Type\Types\MixedType;
+use CuyZ\Valinor\Type\Types\NativeFloatType;
 use CuyZ\Valinor\Type\Types\NativeIntegerType;
 use CuyZ\Valinor\Type\Types\NativeStringType;
+use CuyZ\Valinor\Type\Types\NonEmptyStringType;
 use CuyZ\Valinor\Type\Types\ShapedArrayElement;
 use CuyZ\Valinor\Type\Types\ShapedArrayType;
 use CuyZ\Valinor\Type\Types\StringValueType;
@@ -26,6 +27,8 @@ final class ShapedArrayTypeTest extends TestCase
     /** @var ShapedArrayElement[] */
     private array $elements;
 
+    private ArrayType $unsealedType;
+
     private ShapedArrayType $type;
 
     protected function setUp(): void
@@ -36,12 +39,17 @@ final class ShapedArrayTypeTest extends TestCase
             new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
             new ShapedArrayElement(new IntegerValueType(1337), new NativeIntegerType(), true),
         ];
+        $this->unsealedType = new ArrayType(
+            ArrayKeyType::from(StringValueType::singleQuote('unsealed-key')),
+            new NativeFloatType(),
+        );
 
-        $this->type = new ShapedArrayType(...$this->elements);
+        $this->type = ShapedArrayType::unsealed($this->unsealedType, ...$this->elements);
     }
 
-    public function test_shape_elements_can_be_retrieved(): void
+    public function test_shape_properties_can_be_retrieved(): void
     {
+        self::assertSame($this->unsealedType, $this->type->unsealedType());
         self::assertSame($this->elements, $this->type->elements());
     }
 
@@ -49,31 +57,40 @@ final class ShapedArrayTypeTest extends TestCase
     {
         $this->expectException(ShapedArrayElementDuplicatedKey::class);
         $this->expectExceptionCode(1631283279);
-        $this->expectExceptionMessage('Key `foo` cannot be used several times in shaped array signature `array{foo: string, foo: string}`.');
+        $this->expectExceptionMessage('Key `42` cannot be used several times in shaped array signature `array{42: string, 42: string}`.');
 
         new ShapedArrayType(
-            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
-            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
+            new ShapedArrayElement(new IntegerValueType(42), new NativeStringType()),
+            new ShapedArrayElement(new IntegerValueType(42), new NativeStringType()),
         );
     }
 
     public function test_string_value_is_correct(): void
     {
-        self::assertSame('array{foo: string, 1337?: int}', $this->type->toString());
+        self::assertSame("array{foo: string, 1337?: int, ...array<'unsealed-key', float>}", $this->type->toString());
     }
 
     public function test_accepts_correct_values(): void
     {
+        // Without additional values
         self::assertTrue($this->type->accepts(['foo' => 'foo', 1337 => 42]));
         self::assertTrue($this->type->accepts(['foo' => 'foo']));
+
+        // With additional values
+        self::assertTrue($this->type->accepts(['foo' => 'foo', 1337 => 42, 'unsealed-key' => 42.1337]));
+        self::assertTrue($this->type->accepts(['foo' => 'foo', 'unsealed-key' => 42.1337]));
     }
 
     public function test_does_not_accept_incorrect_values(): void
     {
+        // Without additional values
         self::assertFalse($this->type->accepts(['foo' => 42]));
         self::assertFalse($this->type->accepts(['foo' => new stdClass()]));
         self::assertFalse($this->type->accepts(['bar' => 'foo']));
 
+        // With invalid additional values
+        self::assertFalse($this->type->accepts(['foo' => 'foo', 42 => '']));
+
         self::assertFalse($this->type->accepts(null));
         self::assertFalse($this->type->accepts('Schwifty!'));
         self::assertFalse($this->type->accepts(42.1337));
@@ -82,43 +99,85 @@ final class ShapedArrayTypeTest extends TestCase
         self::assertFalse($this->type->accepts(new stdClass()));
     }
 
-    public function test_does_not_accept_array_with_excessive_keys(): void
-    {
-        self::assertFalse($this->type->accepts(['foo' => 'foo', 'fiz' => 42]));
-    }
-
     public function test_matches_valid_array_shaped_type(): void
     {
-        $otherA = new ShapedArrayType(
+        $otherA = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::string(), new NativeFloatType()),
             new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
-            new ShapedArrayElement(new IntegerValueType(42), new NativeIntegerType()),
+            new ShapedArrayElement(new IntegerValueType(1337), new NativeIntegerType()),
         );
 
-        $otherB = new ShapedArrayType(
-            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType())
+        $otherB = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::string(), new NativeFloatType()),
+            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
+        );
+
+        $otherC = ShapedArrayType::unsealedWithoutType(
+            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
         );
 
         self::assertTrue($this->type->matches($otherA));
         self::assertTrue($this->type->matches($otherB));
+        self::assertTrue($this->type->matches($otherC));
     }
 
-    public function test_does_not_match_invalid_array_shaped_type(): void
+    public function test_unsealed_shaped_array_matches_non_unsealed_shaped_array(): void
     {
-        $otherA = new ShapedArrayType(
+        $unsealedShapedArray = ShapedArrayType::unsealedWithoutType(
+            new ShapedArrayElement(new IntegerValueType(42), new NativeStringType()),
+        );
+
+        $shapedArray = new ShapedArrayType(
+            new ShapedArrayElement(new IntegerValueType(42), new NativeStringType()),
+        );
+
+        self::assertTrue($unsealedShapedArray->matches($shapedArray));
+    }
+
+    public function test_does_not_match_invalid_array_shaped_type_element(): void
+    {
+        // Valid unsealed type, invalid shaped array element type
+        $otherA = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::string(), new NativeFloatType()),
             new ShapedArrayElement(new StringValueType('foo'), new NativeFloatType()),
         );
 
-        $otherB = new ShapedArrayType(
-            new ShapedArrayElement(new StringValueType('bar'), new NativeStringType())
+        // Valid unsealed type, invalid shaped array element key
+        $otherB = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::string(), new NativeFloatType()),
+            new ShapedArrayElement(new StringValueType('bar'), new NativeStringType()),
+        );
+
+        // Valid unsealed type, missing required element
+        $otherC = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::string(), new NativeFloatType()),
+        );
+
+        // Invalid unsealed type, valid shaped array element
+        $otherD = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::string(), new NativeStringType()),
+            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
+        );
+
+        // Invalid unsealed type key, valid shaped array element
+        $otherE = ShapedArrayType::unsealed(
+            new ArrayType(ArrayKeyType::integer(), new NativeFloatType()),
+            new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
         );
 
         self::assertFalse($this->type->matches($otherA));
         self::assertFalse($this->type->matches($otherB));
+        self::assertFalse($this->type->matches($otherC));
+        self::assertFalse($this->type->matches($otherD));
+        self::assertFalse($this->type->matches($otherE));
     }
 
     public function test_matches_valid_generic_array_type(): void
     {
-        $other = new ArrayType(ArrayKeyType::default(), new UnionType(new NativeStringType(), new NativeIntegerType()));
+        $other = new ArrayType(
+            ArrayKeyType::default(),
+            new UnionType(new NativeStringType(), new NativeIntegerType(), new NativeFloatType()),
+        );
 
         self::assertTrue($this->type->matches($other));
     }
@@ -127,7 +186,7 @@ final class ShapedArrayTypeTest extends TestCase
     {
         $otherA = new ArrayType(ArrayKeyType::string(), new UnionType(new NativeStringType(), new NativeIntegerType()));
         $otherB = new ArrayType(ArrayKeyType::integer(), new UnionType(new NativeStringType(), new NativeIntegerType()));
-        $otherC = new ArrayType(ArrayKeyType::default(), new NativeStringType());
+        $otherC = new ArrayType(ArrayKeyType::default(), new UnionType(new NativeStringType(), new NativeIntegerType()));
         $otherD = new ArrayType(ArrayKeyType::default(), new NativeIntegerType());
 
         self::assertFalse($this->type->matches($otherA));
@@ -157,12 +216,27 @@ final class ShapedArrayTypeTest extends TestCase
         self::assertTrue($this->type->matches($unionType));
     }
 
-    public function test_does_not_match_union_containing_invalid_type(): void
+    public function test_does_not_match_union_containing_invalid_element(): void
+    {
+        $unionType = new UnionType(
+            new FakeType(),
+            ShapedArrayType::unsealed(
+                new ArrayType(ArrayKeyType::integer(), new NonEmptyStringType()),
+                new ShapedArrayElement(new StringValueType('bar'), new NativeStringType()),
+            ),
+            new FakeType(),
+        );
+
+        self::assertFalse($this->type->matches($unionType));
+    }
+
+    public function test_does_not_match_union_containing_invalid_unsealed_type(): void
     {
         $unionType = new UnionType(
             new FakeType(),
-            new ShapedArrayType(
-                new ShapedArrayElement(new StringValueType('bar'), new NativeStringType())
+            ShapedArrayType::unsealed(
+                new ArrayType(ArrayKeyType::string(), new NativeStringType()),
+                new ShapedArrayElement(new StringValueType('foo'), new NativeStringType()),
             ),
             new FakeType(),
         );
@@ -172,32 +246,40 @@ final class ShapedArrayTypeTest extends TestCase
 
     public function test_traverse_type_yields_sub_types(): void
     {
+        $unsealedType = new ArrayType(ArrayKeyType::integer(), new NonEmptyStringType());
         $subTypeA = new FakeType();
         $subTypeB = new FakeType();
 
-        $type = new ShapedArrayType(
+        $type = ShapedArrayType::unsealed(
+            $unsealedType,
             new ShapedArrayElement(new StringValueType('foo'), $subTypeA),
             new ShapedArrayElement(new StringValueType('bar'), $subTypeB),
         );
 
-        self::assertCount(2, $type->traverse());
+        self::assertCount(4, $type->traverse());
+        self::assertContains($unsealedType, $type->traverse());
         self::assertContains($subTypeA, $type->traverse());
         self::assertContains($subTypeB, $type->traverse());
     }
 
     public function test_traverse_type_yields_types_recursively(): void
     {
+        $unsealedSubType = new NonEmptyStringType();
+        $unsealedType = new ArrayType(ArrayKeyType::integer(), ArrayType::simple($unsealedSubType));
         $subTypeA = new FakeType();
         $subTypeB = new FakeType();
         $compositeTypeA = new FakeCompositeType($subTypeA);
         $compositeTypeB = new FakeCompositeType($subTypeB);
 
-        $type = new ShapedArrayType(
+        $type = ShapedArrayType::unsealed(
+            $unsealedType,
             new ShapedArrayElement(new StringValueType('foo'), $compositeTypeA),
             new ShapedArrayElement(new StringValueType('bar'), $compositeTypeB),
         );
 
-        self::assertCount(4, $type->traverse());
+        self::assertCount(7, $type->traverse());
+        self::assertContains($unsealedType, $type->traverse());
+        self::assertContains($unsealedSubType, $type->traverse());
         self::assertContains($subTypeA, $type->traverse());
         self::assertContains($subTypeB, $type->traverse());
         self::assertContains($compositeTypeA, $type->traverse());