wwwision/types

Tools to create PHP types that adhere to JSON schema like rules

Fund package maintenance!
bwaidelich
Paypal

1.3.0 2024-07-28 16:47 UTC

This package is auto-updated.

Last update: 2024-10-28 18:01:44 UTC


README

Library to narrow the scope of your PHP types with JSON Schema inspired attributes allowing for validating and mapping unknown data.

Usage

This package can be installed via composer:

composer require wwwision/types

Afterward, three steps are required to profit from the type safety of this package.

Given, you have the following Contact entity:

class Contact {
    public function __construct(public string $name, public int $age) {}
}

This class has a couple of issues:

  • The values are mutable, so every part of the system can just change them without control ($contact->name = 'changed';)
  • The values of $name and $age are unbound – this makes the type very fragile. For example, you could specify a name with thousands of characters or a negative number for the age, possibly breaking at the integration level
  • There is no human readably type information – is the $name supposed to be a full name, just the given name or a family name, ...?

0. Create classes for your Value Objects

Note This list is 0-based because that part is slightly out of scope, it is merely a general recommendation

final class ContactName {
    public function __construct(public string $value) {}
}
final class ContactAge {
    public function __construct(public int $value) {}
}

1. Add attributes

By adding one of the provided attributes, schema information and documentation can be added to type classes:

#[Description('The full name of a contact, e.g. "John Doe"')]
#[StringBased(minLength: 1, maxLength: 200)]
final class ContactName {
    public function __construct(public string $value) {}
}

#[Description('The current age of a contact, e.g. 45')]
#[IntegerBased(minimum: 1, maximum: 130)]
final class ContactAge {
    public function __construct(public int $value) {}
}

Note In most cases it makes sense to specify an upper bound for your types because that allows you to re-use that at "the edges" (e.g. for frontend validation and database schemas)

2. Make constructor private and classes immutable

By making constructors private, validation can be enforced providing confidence that the objects don't violate their allowed range. See best practices for more details.

#[Description('The full name of a contact, e.g. "John Doe"')]
#[StringBased(minLength: 1, maxLength: 200)]
final class ContactName {
    private function __construct(public readonly string $value) {}
}

#[Description('The current age of a contact, e.g. 45')]
#[IntegerBased(minimum: 1, maximum: 130)]
final class ContactAge {
    private function __construct(public readonly int $value) {}
}

final class Contact {
    public function __construct(
        public readonly ContactName $name,
        public readonly ContactAge $age,
    ) {}
}

3. Use instantiate() to create instances

With private constructors in place, the instantiate() function should be used to create new instances of the affected classes:

// ...
instantiate(Contact::class, ['name' => 'John Doe', 'age' => 45]);

Note In practice you'll realize that you hardly need to create new Entity/Value Object instances within your application logic but mostly in the infrastructure layer. E.g. a DatabaseContactRepository might return a Contacts object.

Example: Database integration
// ...

#[ListBased(itemClassName: Contact::class)]
final class Contacts implements IteratorAggregate {
    private function __construct(private readonly array $contacts) {}
    
    public function getIterator() : Traversable {
        yield from $this->contacts;
    }
}

interface ContactRepository {
    public function findByName(ContactName $name): Contacts;
}

final class DatabaseContactRepository implements ContactRepository {

    public function __construct(private readonly PDO $pdo) {}

    public function findByName(ContactName $name): Contacts
    {
        $statement = $this->pdo->prepare('SELECT name, age FROM contacts WHERE name = :name');
        $statement->execute(['name' => $name->value]);
        return instantiate(Contacts::class, $statement->fetchAll(PDO::FETCH_ASSOC));
    }
}

Best practices

In order to gain the most with this package, a couple of rules should be considered:

All state fields in the constructor

This package uses reflection to parse the constructors of involved classes. Therefore the constructor should contain every variable that makes up the internal state (IMO that's a good practice anyways). In general you should only allow state changes through the constructor and it's a good idea to mark DTO classes as readonly

Private constructors

In order to allow data to be validated everywhere, there must be no way to instantiate an Integer-, String- or ListBased class other than with the provided instantiate() method.

Therefore, constructors of Value Objects should be private:

#[StringBased]
final class SomeValueObject {
    private function __construct(public readonly string $value) {}
}

Note For Shapes (i.e. composite) objects that rule doesn't apply, because all of their properties are valid if the above rule is followed:

// ...

final class SomeComposite {
    public function __construct(
        public readonly SomeValueObject $alreadyValidated,
        public readonly bool $neverInvalid,
    ) {}
}

// this is fine:
instantiate(SomeComposite::class, ['alreadyValidated' => 'some value', 'neverInvalid' => true]);

// and so is this:
new SomeComposite(instantiate(SomeValueObject::class, 'some value'), true);

Final classes

In my opinion, classes in PHP should be final by default. For the core domain types this is especially true because inheritance could lead to invalid schemas and failing validation. Instead, composition should be used where it applies.

Immutability

In order to guarantee the correctness of the types, there should be no way to change a value without re-applying validation. The easiest way to achieve this, is to make those types immutable – and this comes with some other benefits as well.

The readonly keyword can be used on properties (with PHP 8.2+ even on the class itself) to ensure immutability on the PHP type level.

If types should be updatable from the outside, ...

  • a new instance should be returned
  • and it should not call the private constructor but use instantiate() in order to apply validation
#[StringBased(format: StringTypeFormat::date, pattern: '^1980')]
final class Date {
    private function __construct(public readonly string $value) {}
    
    public function add(\DateInterval $interval): self
    {
        return instantiate(self::class, \DateTimeImmutable::createFromFormat('Y-m-d', $this->value)->add($interval)->format('Y-m-d'));
    }
}

$date = instantiate(Date::class, '1980-12-30');
$date = $date->add(new \DateInterval('P1D'));

// this is fine
assert($date->value === '1980-12-31');

// this is not because of the "pattern"
$date = $date->add(new \DateInterval('P1D'));

// Exception: Failed to cast string of "1981-01-01" to Date: invalid_string (Value does not match regular expression)

Attributes

Description

The Description attribute allows you to add some domain specific documentation to classes and parameters.

Example: Class with description
#[Description('This is a description for this class')]
final class SomeClass {

    public function __construct(
        #[Description('This is some overridden description for this parameter')]
        public readonly bool $someProperty,
    ) {}
}

assert(Parser::getSchema(SomeClass::class)->overriddenPropertyDescription('someProperty') === 'This is some overridden description for this parameter');

IntegerBased

With the IntegerBased attribute you can create Value Objects that represent an integer. It has the optional arguments

  • minimum – to specify the allowed minimum value
  • maximum – to specify the allowed maximum value
Example
#[IntegerBased(minimum: 0, maximum: 123)]
final class SomeIntBased {
    private function __construct(public readonly int $value) {}
}

instantiate(SomeIntBased::class, '-5');

// Exception: Failed to cast string of "-5" to SomeIntBased: too_small (Number must be greater than or equal to 0)

FloatBased

Starting with version 1.2

With the FloatBased attribute you can create Value Objects that represent a floating point number (aka double). It has the optional arguments

  • minimum – to specify the allowed minimum value (as integer or float)
  • maximum – to specify the allowed maximum value (as integer or float)
Example
#[FloatBased(minimum: 12.34, maximum: 30)]
final class SomeFloatBased {
    private function __construct(public readonly float $value) {}
}

instantiate(SomeFloatBased::class, 12);

// Exception: Failed to cast integer value of 12 to SomeFloatBased: too_small (Number must be greater than or equal to 12.340)

StringBased

With the StringBased attribute you can create Value Objects that represent a string. It has the optional arguments

  • minLength – to specify the allowed minimum length of the string
  • maxLength – to specify the allowed maximum length of the string
  • pattern – to specify a regular expression that the string has to match
  • format – one of the predefined formats the string has to satisfy (this is a subset of the JSON Schema string format)
Example: String Value Object with min and max length constraints
#[StringBased(minLength: 1, maxLength: 200)]
final class GivenName {
    private function __construct(public readonly string $value) {}
}

instantiate(GivenName::class, '');

// Exception: Failed to cast string of "" to GivenName: too_small (String must contain at least 1 character(s))
Example: String Value Object with format and pattern constraints

Just like with JSON Schema, format and pattern can be combined to further narrow the type:

#[StringBased(format: StringTypeFormat::email, pattern: '@your.org$')]
final class EmployeeEmailAddress {
    private function __construct(public readonly string $value) {}
}

instantiate(EmployeeEmailAddress::class, 'not@your.org.localhost');

// Exception: Failed to cast string of "not@your.org.localhost" to EmployeeEmailAddress: invalid_string (Value does not match regular expression)

ListBased

With the ListBased attribute you can create generic lists (i.e. collections, arrays, sets, ...) of the specified itemClassName. It has the optional arguments

  • minCount – to specify how many items the list has to contain at least
  • maxCount – to specify how many items the list has to contain at most
Example: Simple generic array
#[StringBased]
final class Hobby {
    private function __construct(public readonly string $value) {}
}

#[ListBased(itemClassName: Hobby::class)]
final class Hobbies implements IteratorAggregate {
    private function __construct(private readonly array $hobbies) {}
    
    public function getIterator() : Traversable {
        yield from $this->hobbies;
    }
}

instantiate(Hobbies::class, ['Soccer', 'Ping Pong', 'Guitar']);
Example: More verbose generic array with type hints and min and max count constraints

The following example shows a more realistic implementation of a List, with:

  • An @implements annotation that allows IDEs and static type analyzers to improve the DX
  • A Description attribute
  • minCount and maxCount validation
  • Countable and JsonSerializable implementation (just as an example, this is not required for the validation to work)
// ...

/**
 * @implements IteratorAggregate<Hobby> 
 */
#[Description('A list of hobbies')]
#[ListBased(itemClassName: Hobby::class, minCount: 1, maxCount: 3)]
final class HobbiesAdvanced implements IteratorAggregate, Countable, JsonSerializable {
    /** @param array<Hobby> $hobbies */
    private function __construct(private readonly array $hobbies) {}
    
    public function getIterator() : Traversable {
        yield from $this->hobbies;
    }
    
    public function count(): int {
        return count($this->hobbies);
    }
    
    public function jsonSerialize() : array {
        return array_values($this->hobbies);
    }
}

instantiate(HobbiesAdvanced::class, ['Soccer', 'Ping Pong', 'Guitar', 'Gaming']);

// Exception: Failed to cast value of type array to HobbiesAdvanced: too_big (Array must contain at most 3 element(s))

Composite types

The examples above demonstrate how to create very specific Value Objects with strict validation and introspection.

Example: Complex composite object
#[StringBased]
final class GivenName {
    private function __construct(public readonly string $value) {}
}

#[StringBased]
final class FamilyName {
    private function __construct(public readonly string $value) {}
}

final class FullName {
    public function __construct(
        public readonly GivenName $givenName,
        public readonly FamilyName $familyName,
    ) {}
}

#[Description('honorific title of a person')]
enum HonorificTitle
{
    #[Description('for men, regardless of marital status, who do not have another professional or academic title')]
    case MR;
    #[Description('for married women who do not have another professional or academic title')]
    case MRS;
    #[Description('for girls, unmarried women and married women who continue to use their maiden name')]
    case MISS;
    #[Description('for women, regardless of marital status or when marital status is unknown')]
    case MS;
    #[Description('for any other title that does not match the above')]
    case OTHER;
}

#[Description('A contact in the system')]
final class Contact {
    public function __construct(
        public readonly HonorificTitle $title,
        public readonly FullName $name,
        #[Description('Whether the contact is registered or not')]
        public bool $isRegistered = false,
    ) {}
}

// Create a Contact instance from an array
$person = instantiate(Contact::class, ['title' => 'MRS', 'name' => ['givenName' => 'Jane', 'familyName' => 'Doe']]);
assert($person->name->familyName->value === 'Doe');
assert($person->isRegistered === false);

// Retrieve the schema for the Contact class
$schema = Parser::getSchema(Contact::class);
assert($schema->getDescription() === 'A contact in the system');
assert($schema->propertySchemas['isRegistered']->getDescription() === 'Whether the contact is registered or not');

Generics

Generics won't make it into PHP most likely (see this video from Brent that explains why that is the case).

The ListBased attribute allows for relatively easily creation of type-safe collections of a specific item type.

Currently you still have to create a custom class for that, but I don't think that this is a big problem because mostly a common collection class won't fit all the specific requirements. For example: PostResults could provide different functions and implementations than a Posts set (the former might be unbound, the latter might have a minCount constraint etc).

Further thoughts

I'm thinking about adding a more generic (no pun intended) way to allow for common classes without having to specify the itemClassName in the attribute but at instantiation time, maybe something along the lines of

#[Generic('TKey', 'TValue')]
final class Collection {
    // ...
}

// won't work as of now:
$posts = generic(Collection::class, $dbRows, TKey: Types::int(), TValue: Types::classOf(Post::class));

But it adds some more oddities and I currently don't really need it becaused of the reasons mentioned above.

Interfaces

Starting with version 1.1, this package allows to refer to interface types.

In order to instantiate an object via its interface, the instance class name has to be specified via the __type key. All remaining array items will be used as usual. For simple objects, that only expect a single scalar value, the __value key can be specified additionally:

interface SimpleOrComplexObject {
    public function render(): string;
}

#[StringBased]
final class SimpleObject implements SimpleOrComplexObject {
    private function __construct(private readonly string $value) {}
    public function render(): string {
        return $this->value;
    }
}

final class ComplexObject implements SimpleOrComplexObject {
    private function __construct(private readonly string $prefix, private readonly string $suffix) {}
    public function render(): string {
        return $this->prefix . $this->suffix;
    }
}

$simpleObject = instantiate(SimpleOrComplexObject::class, ['__type' => SimpleObject::class, '__value' => 'Some value']);
assert($simpleObject instanceof SimpleObject);

$complexObject = instantiate(SimpleOrComplexObject::class, ['__type' => ComplexObject::class, 'prefix' => 'Prefix', 'suffix' => 'Suffix']);
assert($complexObject instanceof ComplexObject);

Especially when working with generic lists, it can be useful to allow for polymorphism, i.e. allow the list to contain any instance of an interface:

Example: Generic list of interfaces
// ...

#[ListBased(itemClassName: SimpleOrComplexObject::class)]
final class SimpleOrComplexObjects implements IteratorAggregate {
    public function __construct(private readonly array $objects) {}
    
    public function getIterator() : Traversable{
        yield from $this->objects;
    }
    
    public function map(Closure $closure): array
    {
        return array_map($closure, $this->objects);
    }
}

$objects = instantiate(SimpleOrComplexObjects::class, [
    ['__type' => SimpleObject::class, '__value' => 'Simple'],
    ['__type' => ComplexObject::class, 'prefix' => 'Com', 'suffix' => 'plex'],
]);

assert($objects->map(fn (SimpleOrComplexObject $o) => $o->render()) === ['Simple', 'Complex']);

Error handling

Errors that occur during the instantiation of objects lead to an InvalidArgumentException to be thrown. That exception contains a human-readable error message that can be helpful to debug any errors, for example:

Failed to instantiate FullNames: At key "0": At property "givenName": Value "a" does not have the required minimum length of 3 characters

Starting with version 1.2, the more specific CoerceException is thrown with an improved exception message that collects all failures:

Failed to cast value of type array to FullNames: At "0.givenName": too_small (String must contain at least 3 character(s)). At "1.familyName": invalid_type (Required)

In addition, the exception contains a property issues that allows for programmatic parsing and/or rewriting of the error messages. The exception itself is JSON-serializable and the above example would be equivalent to:

[
  {
    "code": "too_small",
    "message": "String must contain at least 3 character(s)",
    "path": [0, "givenName"],
    "type": "string",
    "minimum": 3,
    "inclusive": true,
    "exact": false
  },
  {
    "code": "invalid_type",
    "message": "Required",
    "path": [1, "familyName"],
    "expected": "string",
    "received": "undefined"
  }
]

Note If the syntax is familiar to you, that's no surpise. It is inspired (and in fact almost completely compatible) with the issue format of the fantastic Zod library

Integrations

The declarative approach of this library allows for some interesting integrations. So far, the following two exist – Feel free to create another one and I will gladly add it to this list:

  • types/graphql – to create GraphQL schemas from PHP types
  • types/glossary – to create Markdown glossaries for all relevant PHP types

Dependencies

This package currently relies on the following 3rd party libraries:

...and has the following DEV-requirements:

Performance

This package uses Reflection in order to introspect types. So it comes with a performance hit. Fortunately the performance of Reflection in PHP is not as bad as its reputation and while you can certainly measure a difference, I doubt that it will have a notable effect in practice – unless you are dealing with extremely time critical applications like realtime trading in which case you should not be using PHP in the first place... And you should probably reconsider your life choices in general :)

Nevertheless, this package contains a runtime cache for all reflected classes. So if you return a huge list of the same type, the performance impact should be minimal. I am measuring performance of the API via PHPBench to avoid regressions, and I might add further caches if performance turns out to become an issue.

Contribution

Contributions in the form of issues, pull requests or discussions are highly appreciated

License

See LICENSE