rmb32 / typed-json
Maps decoded JSON onto typed PHP objects, reporting every fault at once with its path
Requires
- php: ^8.5
- ext-ds: ^2.0
- cuyz/valinor: ^2.6
- php-ds/php-ds: ^2.0
Requires (Dev)
- deptrac/deptrac: ^4.7
- infection/infection: ^0.35
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^13.4
- rector/rector: ^2.7
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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-ds2.0 (thephp-ds/php-dspolyfill 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
mixedis a programming error and fails loudly, so a collection cannot silently lose its member types. - Scalars are not cast.
"1"is not anint, and1is not astring.
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.