Repository: CuyZ/Valinor (php)

Issue:
allowSuperfluousKeys and union do not work together
Consider the following example:

```php
<?php

use CuyZ\Valinor\Mapper\Source\Source;
use CuyZ\Valinor\MapperBuilder;

require('vendor/autoload.php');

$payload = (new MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        <<<'EOT'
            array {
                status: 200,
                foo: array {
                    bar: string,
                },
            } | array {
                status: 400,
                error: string,
            }
            EOT,
        Source::json(<<<'EOT'
                {
                    "status": 200,
                    "foo": {
                        "bar": "xxx"
                    }
                }
                EOT)
    );

var_dump($payload);
```

My schema is a partial API response, because I do not care about additional keys. This first example validates properly: There are no additional keys. However if I add one:

```php
<?php

use CuyZ\Valinor\Mapper\Source\Source;
use CuyZ\Valinor\MapperBuilder;

require('vendor/autoload.php');

$payload = (new MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        <<<'EOT'
            array {
                status: 200,
                foo: array {
                    bar: string,
                },
            } | array {
                status: 400,
                error: string,
            }
            EOT,
        Source::json(<<<'EOT'
                {
                    "status": 200,
                    "foo": {
                        "bar": "xxx",
                        "yyy": "zzz"
                    }
                }
                EOT)
    );

var_dump($payload);
```

… the response fails to validate:

> Fatal error: Uncaught CuyZ\Valinor\Mapper\TypeTreeMapperError: Could not map type `array{status: 200, foo: array{bar: string}}|array{status: 400, error: string}`. An error occurred at path *root*: Value object(CuyZ\Valinor\Mapper\Source\Source) does not match any of `array{status: 200, foo: array{bar: string}}`, `array{status: 400, error: string}`. in /pwd/src/Mapper/TypeTreeMapper.php on line 29

It works properly if I remove the top-level union:

```php
<?php

use CuyZ\Valinor\Mapper\Source\Source;
use CuyZ\Valinor\MapperBuilder;

require('vendor/autoload.php');

$payload = (new MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        <<<'EOT'
            array {
                status: 200,
                foo: array {
                    bar: string,
                },
            }
            EOT,
        Source::json(<<<'EOT'
                {
                    "status": 200,
                    "foo": {
                        "bar": "xxx",
                        "yyy": "zzz"
                    }
                }
                EOT)
    );

var_dump($payload);
```
Union of scalar and array of classes does not work
Hi, when trying to map union of scalar and array of classes, i get error:
```
CuyZ\Valinor\Mapper\TypeTreeMapperError: Could not map type `Foo`. An error occurred at path items: Invalid value array{0: array{…}}.
```
```php
(new MapperBuilder())->mapper()->map(Foo::class, Source::array([
    'items' => [
        [
            'id' => 1,
            'value' => 'baz',
        ],
    ],
]));
```

```php
readonly final class Foo
{
    public function __construct(
        /** @var string|list<Bar> */
        public string|array $items,
    ) {
    }
}
```

```php
readonly final class Bar
{
    public function __construct(
        public int $id,
        public string $value,
    ) {
    }
}
```

When trying as source the string value (first type of the union) it works as expected. 
```php
(new MapperBuilder())->mapper()->map(Foo::class, Source::array([
    'items' => 'some value',
]));
```

Currently my workaround is to not map it to array of objects but to union of string and array shape and do the mapping later separately which works but it is not as comfortable as it could be. 

Pull request description:
feat: improve union type narrowing during mapping
The algorithm used by the mapper to narrow a union type has been greatly improved, and should cover more edge-cases that would previously prevent the mapper from performing well.

If an interface, a class or a shaped array is matched by the input, it will take precedence over arrays or scalars.

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(
        signature: 'array<int>|' . Color::class,
        source: [
            'red' => 255,
            'green' => 128,
            'blue' => 64,
        ],
    ); // Returns an instance of `Color`
```

When superfluous keys are allowed, if the input matches several interfaces, classes or shaped array, the one with the most children node will be prioritized, as it is considered the most specific type:

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        // Even if the first shaped array matches the input, the second one is
        // used because it's more specific.
        signature: 'array{foo: int}|array{foo: int, bar: int}',
        source: [
            'foo' => 42,
            'bar' => 1337,
        ],
    );
```

If the input matches several types within the union, a collision will occur and cause the mapper to fail:

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(
        // Even if the first shaped array matches the input, the second one is
        // used because it's more specific.
        signature: 'array{red: int, green: int, blue: int}|' . Color::class,
        source: [
            'red' => 255,
            'green' => 128,
            'blue' => 64,
        ],
    );

// ⚠️ Invalid value array{red: 255, green: 128, blue: 64}, it matches at
//    least two types from union.
```

Fixes #395
Fixes #356
Closes #487
Closes #495

Patch:
diff --git a/docs/pages/usage/type-reference.md b/docs/pages/usage/type-reference.md
index 76f73ff..5a6752e 100644
--- a/docs/pages/usage/type-reference.md
+++ b/docs/pages/usage/type-reference.md
@@ -153,10 +153,10 @@ final class SomeClass
         private array $unionInsideArray,
         
         /** @var int|true */
-        private int|bool $unionWithLiteralTrueType;
+        private int|bool $unionWithLiteralTrueType,
         
         /** @var int|false */
-        private int|bool $unionWithLiteralFalseType;
+        private int|bool $unionWithLiteralFalseType,
         
         /** @var 404.42|1337.42 */
         private float $unionOfFloatValues,
diff --git a/src/Library/Container.php b/src/Library/Container.php
index 8ba5601..85c6677 100644
--- a/src/Library/Container.php
+++ b/src/Library/Container.php
@@ -35,6 +35,7 @@ use CuyZ\Valinor\Mapper\Tree\Builder\IterableNodeBuilder;
 use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder;
 use CuyZ\Valinor\Mapper\Tree\Builder\NativeClassNodeBuilder;
 use CuyZ\Valinor\Mapper\Tree\Builder\NodeBuilder;
+use CuyZ\Valinor\Mapper\Tree\Builder\NullNodeBuilder;
 use CuyZ\Valinor\Mapper\Tree\Builder\ObjectImplementations;
 use CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder;
 use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder;
@@ -63,6 +64,7 @@ use CuyZ\Valinor\Type\Types\IterableType;
 use CuyZ\Valinor\Type\Types\ListType;
 use CuyZ\Valinor\Type\Types\NonEmptyArrayType;
 use CuyZ\Valinor\Type\Types\NonEmptyListType;
+use CuyZ\Valinor\Type\Types\NullType;
 use CuyZ\Valinor\Type\Types\ShapedArrayType;
 use Psr\SimpleCache\CacheInterface;
 
@@ -108,6 +110,7 @@ final class Container
                     IterableType::class => $arrayNodeBuilder,
                     ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->allowSuperfluousKeys),
                     ScalarType::class => new ScalarNodeBuilder($settings->enableFlexibleCasting),
+                    NullType::class => new NullNodeBuilder(),
                     ClassType::class => new NativeClassNodeBuilder(
                         $this->get(ClassDefinitionRepository::class),
                         $this->get(ObjectBuilderFactory::class),
@@ -116,13 +119,7 @@ final class Container
                     ),
                 ]);
 
-                $builder = new UnionNodeBuilder(
-                    $builder,
-                    $this->get(ClassDefinitionRepository::class),
-                    $this->get(ObjectBuilderFactory::class),
-                    $this->get(ObjectNodeBuilder::class),
-                    $settings->enableFlexibleCasting
-                );
+                $builder = new UnionNodeBuilder($builder);
 
                 $builder = new InterfaceNodeBuilder(
                     $builder,
diff --git a/src/Mapper/Object/FilteredObjectBuilder.php b/src/Mapper/Object/FilteredObjectBuilder.php
index 2bee973..acabadf 100644
--- a/src/Mapper/Object/FilteredObjectBuilder.php
+++ b/src/Mapper/Object/FilteredObjectBuilder.php
@@ -16,17 +16,23 @@ final class FilteredObjectBuilder implements ObjectBuilder
 {
     private ObjectBuilder $delegate;
 
-    private Arguments $arguments;
-
-    public function __construct(mixed $source, ObjectBuilder ...$builders)
+    private function __construct(mixed $source, ObjectBuilder ...$builders)
     {
         $this->delegate = $this->filterBuilder($source, ...$builders);
-        $this->arguments = $this->delegate->describeArguments();
+    }
+
+    public static function from(mixed $source, ObjectBuilder ...$builders): ObjectBuilder
+    {
+        if (count($builders) === 1) {
+            return $builders[0];
+        }
+
+        return new self($source, ...$builders);
     }
 
     public function describeArguments(): Arguments
     {
-        return $this->arguments;
+        return $this->delegate->describeArguments();
     }
 
     public function build(array $arguments): object
diff --git a/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php b/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php
index db2de42..9a50c34 100644
--- a/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php
@@ -5,6 +5,10 @@ declare(strict_types=1);
 namespace CuyZ\Valinor\Mapper\Tree\Builder;
 
 use CuyZ\Valinor\Mapper\Tree\Shell;
+use CuyZ\Valinor\Type\CompositeTraversableType;
+use CuyZ\Valinor\Type\Type;
+use CuyZ\Valinor\Type\Types\ShapedArrayType;
+use CuyZ\Valinor\Type\Types\UnionType;
 
 /** @internal */
 final class CasterProxyNodeBuilder implements NodeBuilder
@@ -16,11 +20,28 @@ final class CasterProxyNodeBuilder implements NodeBuilder
         if ($shell->hasValue()) {
             $value = $shell->value();
 
-            if ($shell->type()->accepts($value)) {
+            if ($this->typeAcceptsValue($shell->type(), $value)) {
                 return TreeNode::leaf($shell, $value);
             }
         }
 
         return $this->delegate->build($shell, $rootBuilder);
     }
+
+    private function typeAcceptsValue(Type $type, mixed $value): bool
+    {
+        if ($type instanceof UnionType) {
+            foreach ($type->types() as $subType) {
+                if ($this->typeAcceptsValue($subType, $value)) {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        return ! $type instanceof CompositeTraversableType
+            && ! $type instanceof ShapedArrayType
+            && $type->accepts($value);
+    }
 }
diff --git a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
index c513883..0959778 100644
--- a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
@@ -78,7 +78,7 @@ final class InterfaceNodeBuilder implements NodeBuilder
         }
 
         $class = $this->classDefinitionRepository->for($classType);
-        $objectBuilder = new FilteredObjectBuilder($shell->value(), ...$this->objectBuilderFactory->for($class));
+        $objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class));
 
         $shell = $this->transformSourceForClass($shell, $arguments, $objectBuilder->describeArguments());
 
diff --git a/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php b/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php
index f129b3d..f6b2d76 100644
--- a/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php
@@ -34,7 +34,7 @@ final class NativeClassNodeBuilder implements NodeBuilder
         }
 
         $class = $this->classDefinitionRepository->for($type);
-        $objectBuilder = new FilteredObjectBuilder($shell->value(), ...$this->objectBuilderFactory->for($class));
+        $objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class));
 
         return $this->objectNodeBuilder->build($objectBuilder, $shell, $rootBuilder);
     }
diff --git a/src/Mapper/Tree/Builder/NullNodeBuilder.php b/src/Mapper/Tree/Builder/NullNodeBuilder.php
new file mode 100644
index 0000000..d7631be
--- /dev/null
+++ b/src/Mapper/Tree/Builder/NullNodeBuilder.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Mapper\Tree\Builder;
+
+use CuyZ\Valinor\Mapper\Tree\Exception\SourceIsNotNull;
+use CuyZ\Valinor\Mapper\Tree\Shell;
+use CuyZ\Valinor\Type\Types\NullType;
+
+use function assert;
+
+/** @internal */
+final class NullNodeBuilder implements NodeBuilder
+{
+    public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
+    {
+        $type = $shell->type();
+        $value = $shell->value();
+
+        assert($type instanceof NullType);
+
+        if ($value !== null) {
+            throw new SourceIsNotNull();
+        }
+
+        return TreeNode::leaf($shell, null);
+    }
+}
diff --git a/src/Mapper/Tree/Builder/TreeNode.php b/src/Mapper/Tree/Builder/TreeNode.php
index 959e775..890c2ec 100644
--- a/src/Mapper/Tree/Builder/TreeNode.php
+++ b/src/Mapper/Tree/Builder/TreeNode.php
@@ -9,6 +9,7 @@ use CuyZ\Valinor\Mapper\Tree\Message\Message;
 use CuyZ\Valinor\Mapper\Tree\Node;
 use CuyZ\Valinor\Mapper\Tree\Shell;
 use CuyZ\Valinor\Type\FloatType;
+use CuyZ\Valinor\Type\Type;
 use Throwable;
 
 use function array_map;
@@ -87,6 +88,19 @@ final class TreeNode
         return $this->shell->name();
     }
 
+    public function type(): Type
+    {
+        return $this->shell->type();
+    }
+
+    /**
+     * @return array<self>
+     */
+    public function children(): array
+    {
+        return $this->children;
+    }
+
     public function isValid(): bool
     {
         return $this->valid;
diff --git a/src/Mapper/Tree/Builder/UnionNodeBuilder.php b/src/Mapper/Tree/Builder/UnionNodeBuilder.php
index eebd747..c118c2e 100644
--- a/src/Mapper/Tree/Builder/UnionNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/UnionNodeBuilder.php
@@ -4,30 +4,26 @@ declare(strict_types=1);
 
 namespace CuyZ\Valinor\Mapper\Tree\Builder;
 
-use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
-use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory;
-use CuyZ\Valinor\Mapper\Object\FilteredObjectBuilder;
-use CuyZ\Valinor\Mapper\Object\ObjectBuilder;
 use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveTypeFromUnion;
+use CuyZ\Valinor\Mapper\Tree\Exception\TooManyResolvedTypesFromUnion;
 use CuyZ\Valinor\Mapper\Tree\Shell;
-use CuyZ\Valinor\Type\ScalarType;
-use CuyZ\Valinor\Type\Type;
 use CuyZ\Valinor\Type\ClassType;
-use CuyZ\Valinor\Type\Types\NullType;
+use CuyZ\Valinor\Type\FloatType;
+use CuyZ\Valinor\Type\IntegerType;
+use CuyZ\Valinor\Type\ScalarType;
+use CuyZ\Valinor\Type\StringType;
+use CuyZ\Valinor\Type\Types\InterfaceType;
+use CuyZ\Valinor\Type\Types\ShapedArrayType;
 use CuyZ\Valinor\Type\Types\UnionType;
 
 use function count;
+use function krsort;
+use function reset;
 
 /** @internal */
 final class UnionNodeBuilder implements NodeBuilder
 {
-    public function __construct(
-        private NodeBuilder $delegate,
-        private ClassDefinitionRepository $classDefinitionRepository,
-        private ObjectBuilderFactory $objectBuilderFactory,
-        private ObjectNodeBuilder $objectNodeBuilder,
-        private bool $enableFlexibleCasting
-    ) {}
+    public function __construct(private NodeBuilder $delegate) {}
 
     public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
     {
@@ -37,72 +33,78 @@ final class UnionNodeBuilder implements NodeBuilder
             return $this->delegate->build($shell, $rootBuilder);
         }
 
-        $classNode = $this->tryToBuildClassNode($type, $shell, $rootBuilder);
-
-        if ($classNode instanceof TreeNode) {
-            return $classNode;
-        }
+        $structs = [];
+        $scalars = [];
+        $all = [];
 
-        $narrowedType = $this->narrow($type, $shell->value());
+        foreach ($type->types() as $subType) {
+            $node = $rootBuilder->build($shell->withType($subType));
 
-        return $rootBuilder->build($shell->withType($narrowedType));
-    }
-
-    private function narrow(UnionType $type, mixed $source): Type
-    {
-        $subTypes = $type->types();
-
-        if ($source !== null && count($subTypes) === 2) {
-            if ($subTypes[0] instanceof NullType) {
-                return $subTypes[1];
-            } elseif ($subTypes[1] instanceof NullType) {
-                return $subTypes[0];
-            }
-        }
-
-        foreach ($subTypes as $subType) {
-            if (! $subType instanceof ScalarType) {
+            if (! $node->isValid()) {
                 continue;
             }
 
-            if (! $this->enableFlexibleCasting) {
-                continue;
-            }
+            $all[] = $node;
 
-            if ($subType->canCast($source)) {
-                return $subType;
+            if ($subType instanceof InterfaceType || $subType instanceof ClassType || $subType instanceof ShapedArrayType) {
+                $structs[] = $node;
+            } elseif ($subType instanceof ScalarType) {
+                $scalars[] = $node;
             }
         }
 
-        throw new CannotResolveTypeFromUnion($source, $type);
-    }
-
-    private function tryToBuildClassNode(UnionType $type, Shell $shell, RootNodeBuilder $rootBuilder): ?TreeNode
-    {
-        $classTypes = array_filter(
-            $type->types(),
-            fn (Type $type) => $type instanceof ClassType,
-        );
+        if ($all === []) {
+            throw new CannotResolveTypeFromUnion($shell->value(), $type);
+        }
 
-        if (count($classTypes) === 0) {
-            return null;
+        if (count($all) === 1) {
+            return $all[0];
         }
 
-        $objectBuilder = $this->objectBuilder($shell->value(), ...$classTypes);
+        if ($structs !== []) {
+            // Structs can be either an interface, a class or a shaped array.
+            // We prioritize the one with the most children, as it's the most
+            // specific type. If there are multiple types with the same number
+            // of children, we consider it as a collision.
+            $childrenCount = [];
 
-        return $this->objectNodeBuilder->build($objectBuilder, $shell, $rootBuilder);
-    }
+            foreach ($structs as $node) {
+                $childrenCount[count($node->children())][] = $node;
+            }
 
-    private function objectBuilder(mixed $value, ClassType ...$types): ObjectBuilder
-    {
-        $builders = [];
+            krsort($childrenCount);
+
+            $first = reset($childrenCount);
+
+            if (count($first) === 1) {
+                return $first[0];
+            }
+        } elseif ($scalars !== []) {
+            // Sorting the scalar types by priority: int, float, string, bool.
+            $sorted = [];
+
+            foreach ($scalars as $node) {
+                if ($node->type() instanceof IntegerType) {
+                    $sorted[IntegerType::class] = $node;
+                } elseif ($node->type() instanceof FloatType) {
+                    $sorted[FloatType::class] = $node;
+                } elseif ($node->type() instanceof StringType) {
+                    $sorted[StringType::class] = $node;
+                }
+            }
 
-        foreach ($types as $type) {
-            $class = $this->classDefinitionRepository->for($type);
+            if (isset($sorted[IntegerType::class])) {
+                return $sorted[IntegerType::class];
+            } elseif (isset($sorted[FloatType::class])) {
+                return $sorted[FloatType::class];
+            } elseif (isset($sorted[StringType::class])) {
+                return $sorted[StringType::class];
+            }
 
-            $builders = [...$builders, ...$this->objectBuilderFactory->for($class)];
+            // @infection-ignore-all / We know this is a boolean, so we don't need to mutate the index
+            return $scalars[0];
         }
 
-        return new FilteredObjectBuilder($value, ...$builders);
+        throw new TooManyResolvedTypesFromUnion($type);
     }
 }
diff --git a/src/Mapper/Tree/Exception/SourceIsNotNull.php b/src/Mapper/Tree/Exception/SourceIsNotNull.php
new file mode 100644
index 0000000..a07f436
--- /dev/null
+++ b/src/Mapper/Tree/Exception/SourceIsNotNull.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Mapper\Tree\Exception;
+
+use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage;
+use RuntimeException;
+
+/** @internal */
+final class SourceIsNotNull extends RuntimeException implements ErrorMessage
+{
+    private string $body;
+
+    public function __construct()
+    {
+        $this->body = 'Value {source_value} is not null.';
+
+        parent::__construct($this->body, 1710263908);
+    }
+
+    public function body(): string
+    {
+        return $this->body;
+    }
+}
diff --git a/src/Mapper/Tree/Exception/TooManyResolvedTypesFromUnion.php b/src/Mapper/Tree/Exception/TooManyResolvedTypesFromUnion.php
new file mode 100644
index 0000000..857eaae
--- /dev/null
+++ b/src/Mapper/Tree/Exception/TooManyResolvedTypesFromUnion.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace CuyZ\Valinor\Mapper\Tree\Exception;
+
+use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage;
+use CuyZ\Valinor\Mapper\Tree\Message\HasParameters;
+use CuyZ\Valinor\Type\Types\UnionType;
+use CuyZ\Valinor\Utility\String\StringFormatter;
+use CuyZ\Valinor\Utility\TypeHelper;
+use RuntimeException;
+
+use function array_map;
+use function implode;
+
+/** @internal */
+final class TooManyResolvedTypesFromUnion extends RuntimeException implements ErrorMessage, HasParameters
+{
+    private string $body;
+
+    /** @var array<string, string> */
+    private array $parameters;
+
+    public function __construct(UnionType $unionType)
+    {
+        $this->parameters = [
+            'allowed_types' => implode(
+                ', ',
+                array_map(TypeHelper::dump(...), $unionType->types())
+            ),
+        ];
+
+        $this->body = TypeHelper::containsObject($unionType)
+            ? 'Invalid value {source_value}, it matches two or more types from union: cannot take a decision.'
+            : 'Invalid value {source_value}, it matches two or more types from {allowed_types}: cannot take a decision.';
+
+        parent::__construct(StringFormatter::for($this), 1710262975);
+    }
+
+    public function body(): string
+    {
+        return $this->body;
+    }
+
+    public function parameters(): array
+    {
+        return $this->parameters;
+    }
+}
diff --git a/src/Mapper/Tree/Message/DefaultMessage.php b/src/Mapper/Tree/Message/DefaultMessage.php
index b35d0f5..3fdb96b 100644
--- a/src/Mapper/Tree/Message/DefaultMessage.php
+++ b/src/Mapper/Tree/Message/DefaultMessage.php
@@ -35,6 +35,9 @@ interface DefaultMessage
         'Value {source_value} does not match string value {expected_value}.' => [
             'en' => 'Value {source_value} does not match string value {expected_value}.',
         ],
+        'Value {source_value} is not null.' => [
+            'en' => 'Value {source_value} is not null.',
+        ],
         'Value {source_value} is not a valid boolean.' => [
             'en' => 'Value {source_value} is not a valid boolean.',
         ],
@@ -74,6 +77,12 @@ interface DefaultMessage
         'Invalid value {source_value}.' => [
             'en' => 'Invalid value {source_value}.',
         ],
+        'Invalid value {source_value}, it matches at least two types from union.' => [
+            'en' => 'Invalid value {source_value}, it matches at least two types from union.',
+        ],
+        'Invalid value {source_value}, it matches at least two types from {allowed_types}.' => [
+            'en' => 'Invalid value {source_value}, it matches at least two types from {allowed_types}.',
+        ],
         'Invalid sequential key {key}, expected {expected}.' => [
             'en' => 'Invalid sequential key {key}, expected {expected}.',
         ],


Test patch:
diff --git a/tests/Integration/Mapping/Object/NullableMappingTest.php b/tests/Integration/Mapping/Object/NullableMappingTest.php
index 5bfd203..4a24420 100644
--- a/tests/Integration/Mapping/Object/NullableMappingTest.php
+++ b/tests/Integration/Mapping/Object/NullableMappingTest.php
@@ -20,6 +20,31 @@ final class NullableMappingTest extends IntegrationTestCase
         self::assertSame(null, $result->nullableWithNull);
         self::assertSame('foo', $result->nullableWithString);
     }
+
+    public function test_null_value_mapped_in_null_is_handled_properly(): void
+    {
+        try {
+            $result = $this->mapperBuilder()->mapper()->map('null', null);
+        } catch (MappingError $error) {
+            $this->mappingFail($error);
+        }
+
+        self::assertSame(null, $result); // @phpstan-ignore-line
+    }
+
+    public function test_string_value_mapped_in_null_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()->mapper()->map('null', 'foo');
+
+            self::fail('No mapping error when one was expected');
+        } catch (MappingError $exception) {
+            $error = $exception->node()->messages()[0];
+
+            self::assertSame('1710263908', $error->code());
+            self::assertSame("Value 'foo' is not null.", (string)$error);
+        }
+    }
 }
 
 final class NullablePropertyWithNullDefaultValue
diff --git a/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php b/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
index 22fa861..72345e4 100644
--- a/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
+++ b/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
@@ -6,7 +6,6 @@ namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
 
 use CuyZ\Valinor\Mapper\MappingError;
 use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;
-use PHPUnit\Framework\Attributes\DataProvider;
 
 final class UnionOfObjectsMappingTest extends IntegrationTestCase
 {
@@ -50,25 +49,6 @@ final class UnionOfObjectsMappingTest extends IntegrationTestCase
         self::assertSame('fiz', $result->fiz);
     }
 
-    /**
-     *
-     * @param class-string $className
-     * @param mixed[] $source
-     */
-    #[DataProvider('mapping_error_when_cannot_resolve_union_data_provider')]
-    public function test_mapping_error_when_cannot_resolve_union(string $className, array $source): void
-    {
-        try {
-            $this->mapperBuilder()->mapper()->map($className, $source);
-
-            self::fail('No mapping error when one was expected');
-        } catch (MappingError $exception) {
-            $error = $exception->node()->children()[0]->messages()[0];
-
-            self::assertSame('1642787246', $error->code());
-        }
-    }
-
     public static function mapping_error_when_cannot_resolve_union_data_provider(): iterable
     {
         yield [
diff --git a/tests/Integration/Mapping/UnionMappingTest.php b/tests/Integration/Mapping/UnionMappingTest.php
index 20ccebb..6465f90 100644
--- a/tests/Integration/Mapping/UnionMappingTest.php
+++ b/tests/Integration/Mapping/UnionMappingTest.php
@@ -6,66 +6,361 @@ namespace CuyZ\Valinor\Tests\Integration\Mapping;
 
 use CuyZ\Valinor\Mapper\MappingError;
 use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;
-use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\City;
-use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject;
+use PHPUnit\Framework\Attributes\DataProvider;
 
 final class UnionMappingTest extends IntegrationTestCase
 {
-    public function test_union_with_int_or_object(): void
+    #[DataProvider('union_mapping_works_properly_data_provider')]
+    public function test_union_mapping_works_properly(string $type, mixed $source, callable $assertion): void
     {
         try {
-            $array = $this->mapperBuilder()->mapper()->map("list<int|" . SimpleObject::class . ">", [123, "foo"]);
+            $result = $this->mapperBuilder()
+                ->infer(SomeInterfaceForObjectWithOneStringValue::class, fn () => SomeObjectWithOneStringValue::class)
+                ->mapper()
+                ->map($type, $source);
+
+            $assertion($result);
         } catch (MappingError $error) {
             $this->mappingFail($error);
         }
-
-        self::assertSame(123, $array[0]);
-        self::assertInstanceOf(SimpleObject::class, $array[1]);
     }
 
-    public function test_union_with_string_or_object_prioritizes_string(): void
+    #[DataProvider('union_mapping_works_properly_data_provider')]
+    public function test_union_mapping_works_properly_with_superfluous_keys_allowed(string $type, mixed $source, callable $assertion): void
     {
         try {
-            $array = $this->mapperBuilder()
+            $result = $this->mapperBuilder()
+                ->infer(SomeInterfaceForObjectWithOneStringValue::class, fn () => SomeObjectWithOneStringValue::class)
+                ->allowSuperfluousKeys()
                 ->mapper()
-                ->map("list<string|" . SimpleObject::class . ">", ["foo", "bar", "baz"]);
+                ->map($type, $source);
+
+            $assertion($result);
         } catch (MappingError $error) {
             $this->mappingFail($error);
         }
-
-        self::assertSame(["foo", "bar", "baz"], $array);
     }
 
-    public function test_union_with_string_literal_or_object_prioritizes_string_literal(): void
+    #[DataProvider('union_mapping_works_properly_with_flexible_casting_enabled_data_provider')]
+    public function test_union_mapping_works_properly_with_flexible_casting_enabled(string $type, mixed $source, callable $assertion): void
     {
         try {
-            $array = $this->mapperBuilder()
+            $result = $this->mapperBuilder()
+                ->enableFlexibleCasting()
                 ->mapper()
-                ->map("list<'foo'|" . SimpleObject::class . "|'bar'>", ["foo", "bar", "baz"]);
+                ->map($type, $source);
+
+            $assertion($result);
         } catch (MappingError $error) {
             $this->mappingFail($error);
         }
+    }
+
+    public static function union_mapping_works_properly_data_provider(): iterable
+    {
+        yield 'string or list of string, with string' => [
+            'type' => 'string|list<string>',
+            'source' => 'foo',
+            'assertion' => fn (mixed $result) => self::assertSame('foo', $result),
+        ];
+
+        yield 'string or list of string, with list of string' => [
+            'type' => 'string|list<string>',
+            'source' => ['foo', 'bar'],
+            'assertion' => fn (mixed $result) => self::assertSame(['foo', 'bar'], $result),
+        ];
+
+        yield 'list of string or list of integer, with list of string' => [
+            'type' => 'list<string>|list<int>',
+            'source' => ['foo', 'bar'],
+            'assertion' => fn (mixed $result) => self::assertSame(['foo', 'bar'], $result),
+        ];
+
+        yield 'list of string or list of integer, with list of integer' => [
+            'type' => 'list<string>|list<int>',
+            'source' => [42, 1337],
+            'assertion' => fn (mixed $result) => self::assertSame([42, 1337], $result),
+        ];
+
+        yield 'shaped array with integer or array of integer, with integer' => [
+            'type' => 'array{key: int|array<int>}',
+            'source' => ['key' => 42],
+            'assertion' => fn (mixed $result) => self::assertSame(['key' => 42], $result),
+        ];
+
+        yield 'shaped array with integer or array of integer, with array of integer' => [
+            'type' => 'array{key: int|array<int>}',
+            'source' => ['key' => [42, 1337]],
+            'assertion' => fn (mixed $result) => self::assertSame(['key' => [42, 1337]], $result),
+        ];
+
+        yield 'shaped array representing http response with status 200' => [
+            'type' => 'array{status: 200, data: array{text: string}} | array{status: 400, error: string}',
+            'source' => ['status' => 200, 'data' => ['text' => 'foo']],
+            'assertion' => fn (mixed $result) => self::assertSame(['status' => 200, 'data' => ['text' => 'foo']], $result),
+        ];
+
+        yield 'shaped array representing http response with status 400' => [
+            'type' => 'array{status: 200, data: array{text: string}} | array{status: 400, error: string}',
+            'source' => ['status' => 400, 'error' => 'foo'],
+            'assertion' => fn (mixed $result) => self::assertSame(['status' => 400, 'error' => 'foo'], $result),
+        ];
+
+        yield 'array of string or object with one string value, with array of string' => [
+            'type' => 'array<string>|' . SomeObjectWithOneStringValue::class,
+            'source' => ['foo', 'bar'],
+            'assertion' => fn (mixed $result) => self::assertSame(['foo', 'bar'], $result),
+        ];
+
+        yield 'array of string or object with one string value, with scalar' => [
+            'type' => 'array<string>|' . SomeObjectWithOneStringValue::class,
+            'source' => 'foo',
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValue::class, $result);
+                self::assertSame('foo', $result->string);
+            },
+        ];
+
+        yield 'array of string or object with one string value, with array containing value for object' => [
+            'type' => 'array<string>|' . SomeObjectWithOneStringValue::class,
+            'source' => ['string' => 'foo'],
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValue::class, $result);
+                self::assertSame('foo', $result->string);
+            },
+        ];
+
+        yield 'string or object with one string value, with string value' => [
+            'type' => 'string|' . SomeObjectWithOneStringValue::class,
+            'source' => 'foo',
+            'assertion' => fn (mixed $result) => self::assertSame('foo', $result),
+        ];
+
+        yield 'string value or object with one string value, with matching string value' => [
+            'type' => "'foo'|" . SomeObjectWithOneStringValue::class,
+            'source' => 'foo',
+            'assertion' => fn (mixed $result) => self::assertSame('foo', $result),
+        ];
 
-        self::assertSame("foo", $array[0]);
-        self::assertSame("bar", $array[1]);
-        self::assertInstanceOf(SimpleObject::class, $array[2]);
+        yield 'string value or object with one string value, with non-matching string value' => [
+            'type' => "'foo'|" . SomeObjectWithOneStringValue::class,
+            'source' => 'bar',
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValue::class, $result);
+                self::assertSame('bar', $result->string);
+            },
+        ];
+
+        yield 'object with one string value or object with one string value and one integer value, with array containing string' => [
+            'type' => SomeObjectWithOneStringValue::class . '|' . SomeObjectWithOneStringValueAndOneIntValue::class,
+            'source' => ['string' => 'foo'],
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValue::class, $result);
+                self::assertSame('foo', $result->string);
+            },
+        ];
+
+        yield 'object with one string value or object with one string value and one integer value, with array containing string and integer' => [
+            'type' => SomeObjectWithOneStringValue::class . '|' . SomeObjectWithOneStringValueAndOneIntValue::class,
+            'source' => [
+                'string' => 'foo',
+                'integer' => 42,
+            ],
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValueAndOneIntValue::class, $result);
+                self::assertSame('foo', $result->string);
+                self::assertSame(42, $result->integer);
+            },
+        ];
+
+        yield 'array of string or interface for object with one string value or object with one string value and one integer value, with array containing string' => [
+            'type' => 'array<string>|' . SomeInterfaceForObjectWithOneStringValue::class . '|' . SomeObjectWithOneStringValueAndOneIntValue::class,
+            'source' => ['string' => 'foo'],
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValue::class, $result);
+                self::assertSame('foo', $result->string);
+            },
+        ];
+
+        yield 'array of string or interface for object with one string value or object with one string value and one integer value, with array containing string and integer' => [
+            'type' => 'array<string>|' . SomeInterfaceForObjectWithOneStringValue::class . '|' . SomeObjectWithOneStringValueAndOneIntValue::class,
+            'source' => [
+                'string' => 'foo',
+                'integer' => 42,
+            ],
+            'assertion' => function (mixed $result) {
+                self::assertInstanceOf(SomeObjectWithOneStringValueAndOneIntValue::class, $result);
+                self::assertSame('foo', $result->string);
+                self::assertSame(42, $result->integer);
+            },
+        ];
+    }
+
+    public static function union_mapping_works_properly_with_flexible_casting_enabled_data_provider(): iterable
+    {
+        yield 'float or integer, with string containing float' => [
+            'type' => 'float|integer',
+            'source' => '42.0',
+            'assertion' => fn (mixed $result) => self::assertSame(42.0, $result),
+        ];
+
+        yield 'float or integer, with string containing integer' => [
+            'type' => 'float|integer',
+            'source' => '42',
+            'assertion' => fn (mixed $result) => self::assertSame(42, $result),
+        ];
+
+        yield 'boolean or integer, with string containing integer' => [
+            'type' => 'bool|int',
+            'source' => '1',
+            'assertion' => fn (mixed $result) => self::assertSame(1, $result),
+        ];
+
+        yield 'integer or boolean, with string containing integer' => [
+            'type' => 'int|bool',
+            'source' => '1',
+            'assertion' => fn (mixed $result) => self::assertSame(1, $result),
+        ];
+
+        yield 'boolean or float, with string containing integer' => [
+            'type' => 'bool|float',
+            'source' => '1',
+            'assertion' => fn (mixed $result) => self::assertSame(1.0, $result),
+        ];
+
+        yield 'float or boolean, with string containing integer' => [
+            'type' => 'float|bool',
+            'source' => '1',
+            'assertion' => fn (mixed $result) => self::assertSame(1.0, $result),
+        ];
+
+        yield 'boolean or string, with integer' => [
+            'type' => 'bool|string',
+            'source' => 1,
+            'assertion' => fn (mixed $result) => self::assertSame('1', $result),
+        ];
+
+        yield 'string or boolean, with integer' => [
+            'type' => 'string|bool',
+            'source' => 1,
+            'assertion' => fn (mixed $result) => self::assertSame('1', $result),
+        ];
+
+        yield 'array of integer or boolean, with integer' => [
+            'type' => 'array<int>|bool|true',
+            'source' => 1,
+            'assertion' => fn (mixed $result) => self::assertSame(true, $result),
+        ];
     }
 
-    public function test_union_of_objects(): void
+    public function test_shaped_array_representing_http_response_with_status_200_with_superfluous_key(): void
+    {
+        $result = $this->mapperBuilder()
+            ->allowSuperfluousKeys()
+            ->mapper()
+            ->map('array{status: 200, data: array{text: string}} | array{status: 400, error: string}', [
+                'status' => 200,
+                'data' => [
+                    'text' => 'foo',
+                    'superfluous' => 'key',
+                ],
+            ]);
+
+        self::assertSame(['status' => 200, 'data' => ['text' => 'foo']], $result);
+    }
+
+    public function test_scalar_value_matching_two_objects_in_union_throws_exception(): void
     {
         try {
-            $array = $this->mapperBuilder()
+            $this->mapperBuilder()
+                ->allowSuperfluousKeys()
+                ->mapper()
+                ->map(SomeObjectWithOneStringValue::class . '|' . AnotherObjectWithOneStringValue::class, 'foo');
+
+            self::fail('No mapping error when one was expected');
+        } catch (MappingError $exception) {
+            $error = $exception->node()->messages()[0];
+
+            self::assertSame('1710262975', $error->code());
+            self::assertSame("Invalid value 'foo', it matches two or more types from union: cannot take a decision.", (string)$error);
+        }
+    }
+
+    public function test_array_value_matching_two_objects_in_union_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()
+                ->allowSuperfluousKeys()
                 ->mapper()
                 ->map(
-                    "list<" . SimpleObject::class . "|" . City::class . ">",
-                    ["foo", ["name" => "foobar", "timeZone" => "UTC"], "baz"],
+                    SomeObjectWithOneStringValue::class . '|' . AnotherObjectWithOneStringValue::class,
+                    ['string' => 'foo'],
                 );
-        } catch (MappingError $error) {
-            $this->mappingFail($error);
+
+            self::fail('No mapping error when one was expected');
+        } catch (MappingError $exception) {
+            $error = $exception->node()->messages()[0];
+
+            self::assertSame('1710262975', $error->code());
+            self::assertSame("Invalid value array{string: 'foo'}, it matches two or more types from union: cannot take a decision.", (string)$error);
+        }
+    }
+
+    public function test_array_value_matching_two_arrays_in_union_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()
+                ->mapper()
+                ->map(
+                    "array<string>|array<'foo'|'bar'>",
+                    ['foo', 'bar'],
+                );
+
+            self::fail('No mapping error when one was expected');
+        } catch (MappingError $exception) {
+            $error = $exception->node()->messages()[0];
+
+            self::assertSame('1710262975', $error->code());
+            self::assertSame("Invalid value array{0: 'foo', 1: 'bar'}, it matches two or more types from `array<string>`, `array<'foo'|'bar'>`: cannot take a decision.", (string)$error);
         }
+    }
+
+    public function test_array_value_matching_array_shape_and_object_in_union_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()
+                ->mapper()
+                ->map(
+                    'array{string: string}|' . SomeObjectWithOneStringValue::class,
+                    ['string' => 'foo'],
+                );
 
-        self::assertInstanceOf(SimpleObject::class, $array[0]);
-        self::assertInstanceOf(City::class, $array[1]);
-        self::assertInstanceOf(SimpleObject::class, $array[2]);
+            self::fail('No mapping error when one was expected');
+        } catch (MappingError $exception) {
+            $error = $exception->node()->messages()[0];
+
+            self::assertSame('1710262975', $error->code());
+            self::assertSame("Invalid value array{string: 'foo'}, it matches two or more types from union: cannot take a decision.", (string)$error);
+        }
     }
 }
+
+interface SomeInterfaceForObjectWithOneStringValue {}
+
+final class SomeObjectWithOneStringValue implements SomeInterfaceForObjectWithOneStringValue
+{
+    public function __construct(public readonly string $string) {}
+}
+
+final class AnotherObjectWithOneStringValue
+{
+    public function __construct(public readonly string $string) {}
+}
+
+final class SomeObjectWithOneStringValueAndOneIntValue
+{
+    public function __construct(
+        public readonly string $string,
+        public readonly int $integer,
+    ) {}
+}