Achieving type inference with Valinor and complex types

Achieving type inference with Valinor and complex types

Premise: what is Valinor for?

Valinor is a PHP mapper that focuses heavily on converting amorphous (mixed) data into well-structured/typed information.

It follows the “parse, don’t validate” principle.

Its job fits the problem space of structural validation, and for most PHP applications, it can aid in removing enormous amounts of code and logic around input handling.

function handleInvoiceCreation(Request $request): Response {
    // Valinor is doing a lot of heavy lifting here: it throws detailed exceptions here too!
    $command = $this->mapper->map(InvoiceCreationCommand::class, $request);

    $this->commandBus->dispatch($command);

    return new Response(204);
}

Beware that Valinor handles structural validation, not contextual validation (see also Form, Command, and Model Validation): it checks if the data fits a shape, not whether the data respects your domain invariants, such as “is this email address already in use?”

Using Valinor with on-the-fly types

The mapper can also be used with strings representing complex types:

The following are all valid mapper usages:

$shoppingListItem = $mapper->map('array{quantity: int, description: string}', $input);

// repetitive, unsafe, annoying:
assert(is_int($row['quantity']));
assert(is_string($row['description']));

$shoppingList->add($shoppingListItem['quantity'], $shoppingListItem['description']);
$csvRows = $mapper->map('list<list{int, string, float}>')

foreach ($csvRows as $row) {
    // repetitive, unsafe, annoying:
    assert(is_int($row[0]));
    assert(is_string($row[1]));
    assert(is_float($row[2]));

    $report->addStatistic(customers: $row[0], comment: $row[1], score: $row[2]);
}
$apiResponse = $mapper->map(Paginable::class . '<' . UserDetails::class . '>')

// we use `@var` here, which is a leap of faith, and may break in future
/** @var Paginable<UserDetails> $apiResponse */

importUsers($apiResponse->items);
processNextPage($apiResponse->nextPage);

(See also the official documentation about using these types).

These examples show how powerful the mapper is, but come at a great cost: static analysis tools like PHPStan, Psalm, Mago and Phan lose type inference whenever a string (instead of a class-string) is used as mapper requested shape.

These static analysis tools don’t understand that a pseudo type-string is given, and therefore, the above $shoppingListItem, $csvRows, $apiResponse are all inferred as mixed, leading to messy assertions/code being added around the mapper usage.

Tricking the type-checker

Since we don’t yet have a type-string pseudo-type, we can trick Valinor and the static analysis tools by extracting our type-string through an anonymous PHP class:

$shoppingListItem = $mapper->map(new readonly class (['quantity' => 1, 'description' => '']) {
    /** @param array{quantity: int, description: string} $wrapped */
    function __construct(public array $wrapped)
    {
    }
}::class, $input)->wrapped;

// this is now 100% type-safe!
$shoppingList->add($shoppingListItem['quantity'], $shoppingListItem['description']);
$csvRows = $mapper->map(new readonly class ([]) {
    /** @param list<list{int, string, float}> $wrapped */
    function __construct(public array $wrapped)
    {
    }
}::class, $input)->wrapped;

// look ma, no runtime assertions!
foreach ($csvRows as $row) {
    $report->addStatistic(customers: $row[0], comment: $row[1], score: $row[2]);
}
$apiResponse = $mapper->map(new readonly class (new PaginatedResult(null, [])) {
    /** @param PaginatedResult<UserDetails> $wrapped */
    function __construct(public PaginatedResult $wrapped) {}
}::class, $input)->wrapped;

// no `@var` declarations anymore, hooray! 
importUsers($apiResponse->items);
processNextPage($apiResponse->nextPage);

A few caveats

The approach outlined in this blogpost has few drawbacks:

  1. if an exception occurs, exception messages are affixed to a wrapped sub-field, which is declared by our anonymous class: consider that, when serializing API response problems
  2. because anonymous classes operate with a constructor call, you must be able to provide a default value, which may be tricky at times: consider creating a concrete class, if you hit that limitation
  3. there is an open issue on Valinor about a bug in handling imported generic types: beware of that, before implementing this approach (or check if it was fixed)

Appendix:

Here’s the test code verifying the behavior we discussed in this blogpost:

<?php

declare(strict_types=1);

namespace Tests\Unit;

use CuyZ\Valinor\Mapper\Source\JsonSource;
use CuyZ\Valinor\MapperBuilder;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;

/**
 * @template Paginated of object
 */
final readonly class PaginatedResult
{
    /**
     * @param list<Paginated> $items
     */
    public function __construct(
        public string|null $nextPage,
        public array       $items,
    )
    {
    }
}

final readonly class UserDetails
{
    /** @param non-empty-string $username */
    public function __construct(
        public string             $username,
        public \DateTimeImmutable $registeredOn,
    )
    {

    }
}

final class DummyTest extends TestCase
{
    public function testDeSerializeMultipleFields(): void
    {
        $mapper = new MapperBuilder()->mapper();

        $result = $mapper->map(
            new readonly class (['quantity' => 1, 'description' => '']) {
                /** @param array{quantity: int, description: string} $wrapped */
                function __construct(public array $wrapped)
                {
                }
            }::class,
            new JsonSource('{"quantity": 1234, "description": "foo"}')
        )->wrapped;

        self::assertSame(1234, $result['quantity']);
        self::assertSame('foo', $result['description']);
    }

    public function testDeSerializeList(): void
    {
        $mapper = new MapperBuilder()->mapper();

        $result = $mapper->map(
            new readonly class ([]) {
                /** @param list<list{int, string, float}> $wrapped */
                function __construct(public array $wrapped)
                {
                }
            }::class,
            new JsonSource('[[1, "foo", 12.34], [2, "bar", 34.56]]')
        )->wrapped;

        self::assertSame([[1, "foo", 12.34], [2, "bar", 34.56]], $result);
    }

    public function testDeSerializeGenericType(): void
    {
        $mapper = new MapperBuilder()->mapper();

        $result = $mapper->map(
            new readonly class (new PaginatedResult(null, [])) {
                /**
                 * @param \Tests\Unit\PaginatedResult<\Tests\Unit\UserDetails> $wrapped
                 */
                function __construct(public PaginatedResult $wrapped)
                {
                }
            }::class,
            new JsonSource(<<<'JSON'
{
    "nextPage": "page3Cursor",
    "items": [
        {"username": "ocramius", "registeredOn": "1784368800"},
        {"username": "cspray", "registeredOn": "1784282400"}
    ]
}
JSON
            ))->wrapped;

        self::assertEquals(
            new PaginatedResult(
                'page3Cursor',
                [
                    new UserDetails(
                        'ocramius',
                        new DateTimeImmutable('@1784368800'),
                    ),
                    new UserDetails(
                        'cspray',
                        new DateTimeImmutable('@1784282400'),
                    ),
                ]
            ),
            $result
        );
    }
}