Search by

rmb32 / typed-json

rogerbarnfather

Maps decoded JSON onto typed PHP objects, reporting every fault at once with its path

Package info

bitbucket.org/rmb32/typed-json

pkg:composer/rmb32/typed-json

Statistics

Installs: 39

Dependents: 1

Suggesters: 0

v1.0.0 2026-10-17 21:13 UTC

This package is auto-updated.

Last update: 2026-10-18 15:08:11 UTC


README

Turn decoded JSON into typed PHP objects, and get back every problem at once, each with the path that caused it.

Your PHP type declarations and docblock generics already describe the shape of the file you are reading. Typed JSON uses them as the schema, so you declare the shape once — as the target type — instead of hand-writing a check per field:

$order = $mapper->map(Order::class, $decoded);

When the data does not fit, you do not get the first error; you get all of them, ready to show in a form, a CLI or an API response:

[reference]        Value 12345 is not a valid string.
[lines.1.quantity] Value 'two' is not a valid integer.
[lines.1.status]   Value 'pendign' does not match any of 'pending', 'shipped'.

It is a thin, opinionated configuration of CuyZ/Valinor — the mapping is Valinor's; the strictness settings, the error shape and the collection and interned-value support are this package's, so every project that installs it reads JSON the same way.

What it does not do: it validates shape and types only. It will not check that a reference points at something that exists, that a graph has no cycles, or any other rule that spans more than one value. Those stay in your domain code.

Requirements

  • PHP 8.5+
  • ext-ds 2.0 (the php-ds/php-ds polyfill is installed with the package)

Installation

composer require rmb32/typed-json

Getting a mapper

One call, no configuration needed:

use Rmb32\TypedJson\Api\Factory\MapperFactory;

$mapper = new MapperFactory()->create();

Mapping

map() takes a class and a decoded value. mapFile() reads and decodes a JSON file for you:

use Rmb32\TypedJson\Api\Exception\MappingFailedException;

try {
    $order = $mapper->map(Order::class, json_decode($json, true));
    $other = $mapper->mapFile(Order::class, '/path/to/order.json');
} catch (MappingFailedException $exception) {
    foreach ($exception->faults() as $fault) {
        echo $fault->path(), ' ', $fault->message(), ' (', $fault->code(), ')', PHP_EOL;
    }
}

A Fault carries three things: path() (dotted, e.g. lines.1.quantity), message() and code() — a stable machine-readable string such as invalid_integer or unexpected_key.

Collections

Ds\Seq, Ds\Map and Ds\Set are final native classes with no template parameter, so no mapper can build one directly. Take the plain list or array in your constructor and wrap it yourself — the member types are still fully enforced, and faults still point at the exact element:

final readonly class Order
{
    /** @var Seq<Line> */
    private Seq $lines;

    /** @param list<Line> $lines */
    public function __construct(array $lines)
    {
        $this->lines = new Seq($lines);
    }
}

For the one case this cannot cover — mapping a collection as the root type — the package ships SeqOf, MapOf and SetOf:

use Rmb32\TypedJson\Api\ValueObject\SeqOf;

$result = $mapper->mapType(SeqOf::class . '<string>', ['a', 'b']);

if ($result instanceof SeqOf) {
    $result->seq();   // Ds\Seq<string>
}

mapType() takes a type signature rather than a class name, so it returns mixed and you narrow it yourself. map() is the typed path and the one to reach for everywhere else. These wrappers put ->seq() between you and your data, so use them only where a collection really is the root.

Interned values

If a value object is registry-backed, so the same string always yields the same instance, name its class and the mapper will build it through new() instead of its constructor. Interning holds: the same value in two places in one document comes back as one instance.

use Rmb32\TypedJson\Api\Contract\InternedValueInterface;
use Rmb32\TypedJson\Api\Factory\MapperFactory;
use Rmb32\TypedJson\Api\ValueObject\Config;

final class Sku implements InternedValueInterface
{
    public static function new(string $value): static { /* ... */ }
}

$mapper = new MapperFactory()->create(new Config(internedValues: [Sku::class]));

If new() rejects a value, throw an exception that implements Valinor's ErrorMessage (and, optionally, HasCode and HasParameters) and it will arrive as an ordinary Fault with your own message and code, alongside every other fault. An exception that does not implement ErrorMessage escapes the mapper unchanged.

Caching

Compiling type definitions is a cold-path cost; mapping itself is unaffected. Point the mapper at a cache directory in production, and at a watching one while you are editing the classes it maps to:

use Rmb32\TypedJson\Api\ValueObject\CachePolicy;

new Config(cache: CachePolicy::directory(__DIR__ . '/var/cache/typed-json'));
new Config(cache: CachePolicy::watchingDirectory(__DIR__ . '/var/cache/typed-json'));

CachePolicy::none() is the default. watchingDirectory() invalidates an entry when the source file of a mapped class changes, which a plain directory cache will not notice.

How strict it is

Deliberately, and not configurable:

  • Superfluous keys are rejected. An unexpected key is a fault (unexpected_key), not a shrug.
  • Permissive types are refused. A target whose type contains mixed is a programming error and fails loudly, so a collection cannot silently lose its member types.
  • Scalars are not cast. "1" is not an int, and 1 is not a string.

Discriminated shapes

Model a tagged shape as one class with an enum discriminator, not a PHP union of branch classes. A union reports a bad tag against the parent node and dumps both branch shapes; an enum reports it where it happened:

[type] Value 'widget' does not match any of 'local', 'borrowed'.

Used in Barn

This package is used in Barn. Learn about Barn.

More docs

Internals · History · Known issues

License

Proprietary. See LICENSE. Copyright (c) Roger Barnfather.