Repository: CuyZ/Valinor (php)

Issue:
Return types for method do not match: `list<array-key>` (docblock) does not accept `array`
First of all, I'm absolutely loving how Valinor is turning into a fully-fledged, runtime equivalent of a static analysis tool, now even with method return type checks for mapped classes!

However, I noticed that in one specific instance a certain check is behaving incorrectly:

```php

class a {
    /** @var array<string> */
    public function __construct(private array $a)
    {
    }

	/**
	 * @return list<array-key>
	 */
	public function getPropertiesList(): array {
		return array_keys($this->a);
	}
}

(new MapperBuilder)->mapper()->map(a::class, ['a' => []]);
```

throws "Return types for method `a::getPropertiesList()` do not match: `list<array-key>` (docblock) does not accept `array`"

Pull request:
feat: allow mapping to `array-key` type
Fixes #300

Patch:
diff --git a/src/Type/CombiningType.php b/src/Type/CombiningType.php
index a659fde..8dbd7f9 100644
--- a/src/Type/CombiningType.php
+++ b/src/Type/CombiningType.php
@@ -10,7 +10,7 @@ interface CombiningType extends CompositeType
     public function isMatchedBy(Type $other): bool;
 
     /**
-     * @return Type[]
+     * @return non-empty-list<Type>
      */
-    public function types(): iterable;
+    public function types(): array;
 }
diff --git a/src/Type/Parser/Lexer/Token/ClassNameToken.php b/src/Type/Parser/Lexer/Token/ClassNameToken.php
index 94f7f72..d1a5e0a 100644
--- a/src/Type/Parser/Lexer/Token/ClassNameToken.php
+++ b/src/Type/Parser/Lexer/Token/ClassNameToken.php
@@ -25,6 +25,7 @@ use ReflectionClassConstant;
 use function array_keys;
 use function array_map;
 use function array_shift;
+use function array_values;
 use function count;
 use function explode;
 
@@ -101,7 +102,7 @@ final class ClassNameToken implements TraversingToken
         $cases = array_map(static fn ($value) => ValueTypeFactory::from($value), $cases);
 
         if (count($cases) > 1) {
-            return new UnionType(...$cases);
+            return new UnionType(...array_values($cases));
         }
 
         return reset($cases);
diff --git a/src/Type/Types/ArrayKeyType.php b/src/Type/Types/ArrayKeyType.php
index dd0bd84..3adc944 100644
--- a/src/Type/Types/ArrayKeyType.php
+++ b/src/Type/Types/ArrayKeyType.php
@@ -4,16 +4,19 @@ declare(strict_types=1);
 
 namespace CuyZ\Valinor\Type\Types;
 
-use CuyZ\Valinor\Type\CombiningType;
+use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage;
+use CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder;
 use CuyZ\Valinor\Type\IntegerType;
 use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey;
+use CuyZ\Valinor\Type\ScalarType;
 use CuyZ\Valinor\Type\StringType;
 use CuyZ\Valinor\Type\Type;
+use LogicException;
 
 use function is_int;
 
 /** @internal */
-final class ArrayKeyType implements Type
+final class ArrayKeyType implements ScalarType
 {
     private static self $default;
 
@@ -21,28 +24,36 @@ final class ArrayKeyType implements Type
 
     private static self $string;
 
-    /** @var array<Type> */
+    /** @var non-empty-list<IntegerType|StringType> */
     private array $types;
 
     private string $signature;
 
     private function __construct(Type $type)
     {
-        $this->signature = $type->toString();
-        $this->types = $type instanceof CombiningType
+        $types = $type instanceof UnionType
             ? [...$type->types()]
             : [$type];
 
-        foreach ($this->types as $subType) {
+        foreach ($types as $subType) {
             if (! $subType instanceof IntegerType && ! $subType instanceof StringType) {
                 throw new InvalidArrayKey($subType);
             }
         }
+
+        /** @var non-empty-list<IntegerType|StringType> $types */
+        $this->types = $types;
+        $this->signature = $type->toString();
     }
 
     public static function default(): self
     {
-        return self::$default ??= new self(new UnionType(NativeIntegerType::get(), NativeStringType::get()));
+        if (!isset(self::$default)) {
+            self::$default = new self(new UnionType(NativeIntegerType::get(), NativeStringType::get()));
+            self::$default->signature = 'array-key';
+        }
+
+        return self::$default;
     }
 
     public static function integer(): self
@@ -86,6 +97,10 @@ final class ArrayKeyType implements Type
             return true;
         }
 
+        if ($other instanceof UnionType) {
+            return $this->isMatchedBy($other);
+        }
+
         if (! $other instanceof self) {
             return false;
         }
@@ -114,6 +129,33 @@ final class ArrayKeyType implements Type
         return false;
     }
 
+    public function canCast(mixed $value): bool
+    {
+        foreach ($this->types as $type) {
+            if ($type->canCast($value)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function cast(mixed $value): string|int
+    {
+        foreach ($this->types as $type) {
+            if ($type->canCast($value)) {
+                return $type->cast($value);
+            }
+        }
+
+        throw new LogicException();
+    }
+
+    public function errorMessage(): ErrorMessage
+    {
+        return MessageBuilder::newError('Value {source_value} is not a valid array key.')->build();
+    }
+
     public function toString(): string
     {
         return $this->signature;
diff --git a/src/Type/Types/IntersectionType.php b/src/Type/Types/IntersectionType.php
index 61724eb..9b220df 100644
--- a/src/Type/Types/IntersectionType.php
+++ b/src/Type/Types/IntersectionType.php
@@ -9,20 +9,21 @@ use CuyZ\Valinor\Type\ObjectType;
 use CuyZ\Valinor\Type\CompositeType;
 use CuyZ\Valinor\Type\Type;
 
+use function array_values;
 use function implode;
 
 /** @internal */
 final class IntersectionType implements CombiningType
 {
-    /** @var ObjectType[] */
+    /** @var non-empty-list<ObjectType> */
     private array $types;
 
     private string $signature;
 
-    public function __construct(ObjectType ...$types)
+    public function __construct(ObjectType $type, ObjectType $otherType, ObjectType ...$otherTypes)
     {
-        $this->types = $types;
-        $this->signature = implode('&', array_map(fn (Type $type) => $type->toString(), $types));
+        $this->types = [$type, $otherType, ...array_values($otherTypes)];
+        $this->signature = implode('&', array_map(fn (Type $type) => $type->toString(), $this->types));
     }
 
     public function accepts(mixed $value): bool
@@ -82,7 +83,7 @@ final class IntersectionType implements CombiningType
     }
 
     /**
-     * @return ObjectType[]
+     * @return non-empty-list<ObjectType>
      */
     public function types(): array
     {
diff --git a/src/Type/Types/UnionType.php b/src/Type/Types/UnionType.php
index c66ce2b..5ed460d 100644
--- a/src/Type/Types/UnionType.php
+++ b/src/Type/Types/UnionType.php
@@ -15,30 +15,34 @@ use function implode;
 /** @internal */
 final class UnionType implements CombiningType
 {
-    /** @var Type[] */
-    private array $types = [];
+    /** @var non-empty-list<Type> */
+    private array $types;
 
     private string $signature;
 
-    public function __construct(Type ...$types)
+    public function __construct(Type $type, Type $otherType, Type ...$otherTypes)
     {
-        $this->signature = implode('|', array_map(fn (Type $type) => $type->toString(), $types));
+        $types = [$type, $otherType, ...$otherTypes];
+        $filteredTypes = [];
 
-        foreach ($types as $type) {
-            if ($type instanceof self) {
-                foreach ($type->types as $subType) {
-                    $this->types[] = $subType;
+        foreach ($types as $subType) {
+            if ($subType instanceof self) {
+                foreach ($subType->types as $anotherSubType) {
+                    $filteredTypes[] = $anotherSubType;
                 }
 
                 continue;
             }
 
-            if ($type instanceof MixedType) {
+            if ($subType instanceof MixedType) {
                 throw new ForbiddenMixedType();
             }
 
-            $this->types[] = $type;
+            $filteredTypes[] = $subType;
         }
+
+        $this->types = $filteredTypes;
+        $this->signature = implode('|', array_map(fn (Type $type) => $type->toString(), $this->types));
     }
 
     public function accepts(mixed $value): bool
@@ -65,12 +69,12 @@ final class UnionType implements CombiningType
         }
 
         foreach ($this->types as $type) {
-            if (! $type->matches($other)) {
-                return false;
+            if ($type->matches($other)) {
+                return true;
             }
         }
 
-        return true;
+        return false;
     }
 
     public function isMatchedBy(Type $other): bool


Test patch:
diff --git a/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php b/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php
index 94b3779..cf3937c 100644
--- a/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php
+++ b/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php
@@ -208,7 +208,7 @@ final class GenericLexerTest extends TestCase
 
         $this->expectException(InvalidAssignedGeneric::class);
         $this->expectExceptionCode(1604613633);
-        $this->expectExceptionMessage("The generic `bool` is not a subtype of `int|string` for the template `Template` of the class `$className`.");
+        $this->expectExceptionMessage("The generic `bool` is not a subtype of `array-key` for the template `Template` of the class `$className`.");
 
         $this->parser->parse("$className<bool>");
     }
diff --git a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php
index e9ba4bf..4755521 100644
--- a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php
+++ b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php
@@ -45,6 +45,8 @@ final class ScalarValuesMappingTest extends IntegrationTestCase
             'classString' => self::class,
             'classStringOfDateTime' => DateTimeImmutable::class,
             'classStringOfAlias' => stdClass::class,
+            'arrayKeyWithString' => 'foo',
+            'arrayKeyWithInteger' => 42,
         ];
 
         foreach ([ScalarValues::class, ScalarValuesWithConstructor::class] as $class) {
@@ -81,6 +83,8 @@ final class ScalarValuesMappingTest extends IntegrationTestCase
             self::assertSame(self::class, $result->classString);
             self::assertSame(DateTimeImmutable::class, $result->classStringOfDateTime);
             self::assertSame(stdClass::class, $result->classStringOfAlias);
+            self::assertSame('foo', $result->arrayKeyWithString);
+            self::assertSame(42, $result->arrayKeyWithInteger);
         }
     }
 
@@ -94,6 +98,17 @@ final class ScalarValuesMappingTest extends IntegrationTestCase
             self::assertSame('Value object(stdClass) is not a valid string.', (string)$error);
         }
     }
+
+    public function test_invalid_array_key_throws_exception(): void
+    {
+        try {
+            $this->mapperBuilder()->mapper()->map('array-key', new stdClass());
+        } catch (MappingError $exception) {
+            $error = $exception->node()->messages()[0];
+
+            self::assertSame('Value object(stdClass) is not a valid array key.', (string)$error);
+        }
+    }
 }
 
 class ScalarValues
@@ -173,6 +188,12 @@ class ScalarValues
 
     /** @var class-string<ObjectAlias> */
     public string $classStringOfAlias;
+
+    /** @var array-key */
+    public string|int $arrayKeyWithString;
+
+    /** @var array-key */
+    public string|int $arrayKeyWithInteger;
 }
 
 class ScalarValuesWithConstructor extends ScalarValues
@@ -200,6 +221,8 @@ class ScalarValuesWithConstructor extends ScalarValues
      * @param class-string $classString
      * @param class-string<DateTimeInterface> $classStringOfDateTime
      * @param class-string<ObjectAlias> $classStringOfAlias
+     * @param array-key $arrayKeyWithString
+     * @param array-key $arrayKeyWithInteger
      */
     public function __construct(
         bool $boolean,
@@ -228,7 +251,9 @@ class ScalarValuesWithConstructor extends ScalarValues
         string $stringValueContainingSpecialCharsWithDoubleQuote,
         string $classString,
         string $classStringOfDateTime,
-        string $classStringOfAlias
+        string $classStringOfAlias,
+        int|string $arrayKeyWithString,
+        int|string $arrayKeyWithInteger,
     ) {
         $this->boolean = $boolean;
         $this->float = $float;
@@ -257,5 +282,7 @@ class ScalarValuesWithConstructor extends ScalarValues
         $this->classString = $classString;
         $this->classStringOfDateTime = $classStringOfDateTime;
         $this->classStringOfAlias = $classStringOfAlias;
+        $this->arrayKeyWithString = $arrayKeyWithString;
+        $this->arrayKeyWithInteger = $arrayKeyWithInteger;
     }
 }
diff --git a/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php b/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php
index 021e2be..e8f36eb 100644
--- a/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php
+++ b/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php
@@ -326,6 +326,17 @@ final class FlexibleCastingMappingTest extends IntegrationTestCase
 
         self::assertSame('foo', $result);
     }
+
+    public function test_source_can_be_casted_to_array_key(): void
+    {
+        try {
+            $result = $this->mapper->map('array-key', new StringableObject('foo'));
+        } catch (MappingError $error) {
+            $this->mappingFail($error);
+        }
+
+        self::assertSame('foo', $result);
+    }
 }
 
 interface SomeInterfaceForClassWithNoProperties {}
diff --git a/tests/Unit/Type/Types/ArrayKeyTypeTest.php b/tests/Unit/Type/Types/ArrayKeyTypeTest.php
index e48bbad..050591a 100644
--- a/tests/Unit/Type/Types/ArrayKeyTypeTest.php
+++ b/tests/Unit/Type/Types/ArrayKeyTypeTest.php
@@ -5,10 +5,12 @@ declare(strict_types=1);
 namespace CuyZ\Valinor\Tests\Unit\Type\Types;
 
 use CuyZ\Valinor\Tests\Fake\Type\FakeType;
+use CuyZ\Valinor\Tests\Fixture\Object\StringableObject;
 use CuyZ\Valinor\Type\Types\ArrayKeyType;
 use CuyZ\Valinor\Type\Types\MixedType;
 use CuyZ\Valinor\Type\Types\NativeIntegerType;
 use CuyZ\Valinor\Type\Types\NativeStringType;
+use LogicException;
 use PHPUnit\Framework\TestCase;
 use stdClass;
 
@@ -25,7 +27,7 @@ final class ArrayKeyTypeTest extends TestCase
 
     public function test_string_values_are_correct(): void
     {
-        self::assertSame('int|string', ArrayKeyType::default()->toString());
+        self::assertSame('array-key', ArrayKeyType::default()->toString());
         self::assertSame('int', ArrayKeyType::integer()->toString());
         self::assertSame('string', ArrayKeyType::string()->toString());
     }
@@ -55,6 +57,75 @@ final class ArrayKeyTypeTest extends TestCase
         self::assertFalse(ArrayKeyType::default()->accepts(new stdClass()));
     }
 
+    public function test_default_array_key_can_cast_numeric_and_string_value(): void
+    {
+        self::assertTrue(ArrayKeyType::default()->canCast(42.1337));
+        self::assertTrue(ArrayKeyType::default()->canCast(404));
+        self::assertTrue(ArrayKeyType::default()->canCast('foo'));
+        self::assertTrue(ArrayKeyType::default()->canCast(new StringableObject('foo')));
+    }
+
+    public function test_default_array_key_cannot_cast_other_values(): void
+    {
+        self::assertFalse(ArrayKeyType::default()->canCast(null));
+        self::assertFalse(ArrayKeyType::default()->canCast(['foo' => 'bar']));
+        self::assertFalse(ArrayKeyType::default()->canCast(false));
+        self::assertFalse(ArrayKeyType::default()->canCast(new stdClass()));
+    }
+
+    public function test_integer_array_key_can_cast_numeric_value(): void
+    {
+        self::assertTrue(ArrayKeyType::integer()->canCast(42));
+    }
+
+    public function test_integer_array_key_cannot_cast_other_values(): void
+    {
+        self::assertFalse(ArrayKeyType::integer()->canCast(null));
+        self::assertFalse(ArrayKeyType::integer()->canCast(42.1337));
+        self::assertFalse(ArrayKeyType::integer()->canCast(['foo' => 'bar']));
+        self::assertFalse(ArrayKeyType::integer()->canCast('Schwifty!'));
+        self::assertFalse(ArrayKeyType::integer()->canCast(false));
+        self::assertFalse(ArrayKeyType::integer()->canCast(new stdClass()));
+    }
+
+    public function test_string_array_key_can_cast_numeric_and_string_value(): void
+    {
+        self::assertTrue(ArrayKeyType::string()->canCast(42.1337));
+        self::assertTrue(ArrayKeyType::string()->canCast(404));
+        self::assertTrue(ArrayKeyType::string()->canCast('foo'));
+        self::assertTrue(ArrayKeyType::string()->canCast(new StringableObject('foo')));
+    }
+
+    public function test_string_array_key_cannot_cast_other_values(): void
+    {
+        self::assertFalse(ArrayKeyType::string()->canCast(null));
+        self::assertFalse(ArrayKeyType::string()->canCast(['foo' => 'bar']));
+        self::assertFalse(ArrayKeyType::string()->canCast(false));
+        self::assertFalse(ArrayKeyType::string()->canCast(new stdClass()));
+    }
+
+    public function test_cast_value_yields_correct_result(): void
+    {
+        self::assertSame(42, ArrayKeyType::default()->cast(42));
+        self::assertSame('42.1337', ArrayKeyType::default()->cast(42.1337));
+        self::assertSame('foo', ArrayKeyType::default()->cast('foo'));
+        self::assertSame('foo', ArrayKeyType::default()->cast(new StringableObject('foo')));
+
+        self::assertSame(42, ArrayKeyType::integer()->cast(42));
+
+        self::assertSame('42', ArrayKeyType::string()->cast(42));
+        self::assertSame('42.1337', ArrayKeyType::string()->cast(42.1337));
+        self::assertSame('foo', ArrayKeyType::string()->cast('foo'));
+        self::assertSame('foo', ArrayKeyType::string()->cast(new StringableObject('foo')));
+    }
+
+    public function test_cast_invalid_value_throw_exception(): void
+    {
+        $this->expectException(LogicException::class);
+
+        ArrayKeyType::default()->cast(new stdClass());
+    }
+
     public function test_matches_each_others(): void
     {
         $arrayKeyDefault = ArrayKeyType::default();
diff --git a/tests/Unit/Type/Types/IntersectionTypeTest.php b/tests/Unit/Type/Types/IntersectionTypeTest.php
index 7281931..5f41767 100644
--- a/tests/Unit/Type/Types/IntersectionTypeTest.php
+++ b/tests/Unit/Type/Types/IntersectionTypeTest.php
@@ -20,8 +20,14 @@ final class IntersectionTypeTest extends TestCase
         $typeA = new FakeObjectType();
         $typeB = new FakeObjectType();
         $typeC = new FakeObjectType();
-
-        $types = (new IntersectionType($typeA, $typeB, $typeC))->types();
+        $typeD = new FakeObjectType();
+
+        $types = (new IntersectionType(
+            $typeA,
+            $typeB,
+            // Putting those in associative array on purpose
+            ...['C' => $typeC, 'D' => $typeD],
+        ))->types();
 
         self::assertSame($typeA, $types[0]);
         self::assertSame($typeB, $types[1]);
diff --git a/tests/Unit/Type/Types/UndefinedObjectTypeTest.php b/tests/Unit/Type/Types/UndefinedObjectTypeTest.php
index 9fad82a..d908fc8 100644
--- a/tests/Unit/Type/Types/UndefinedObjectTypeTest.php
+++ b/tests/Unit/Type/Types/UndefinedObjectTypeTest.php
@@ -79,7 +79,7 @@ final class UndefinedObjectTypeTest extends TestCase
 
     public function test_matches_intersection_type(): void
     {
-        self::assertTrue($this->undefinedObjectType->matches(new IntersectionType()));
+        self::assertTrue($this->undefinedObjectType->matches(new IntersectionType(new FakeObjectType(), new FakeObjectType())));
     }
 
     public function test_matches_mixed_type(): void
diff --git a/tests/Unit/Type/Types/UnionTypeTest.php b/tests/Unit/Type/Types/UnionTypeTest.php
index a444337..2face8a 100644
--- a/tests/Unit/Type/Types/UnionTypeTest.php
+++ b/tests/Unit/Type/Types/UnionTypeTest.php
@@ -133,7 +133,7 @@ final class UnionTypeTest extends TestCase
         $typeC = new FakeType();
 
         $unionTypeA = new UnionType($typeA, $typeB, $typeC);
-        $unionTypeB = new UnionType($typeB);
+        $unionTypeB = new UnionType($typeB, $typeC);
 
         self::assertFalse($unionTypeA->matches($unionTypeB));
     }