Skip to content

Instantly share code, notes, and snippets.

@butschster
Last active March 25, 2025 15:42
Show Gist options
  • Save butschster/a55b6df01fc1260cfb28ad349e2ce755 to your computer and use it in GitHub Desktop.
Save butschster/a55b6df01fc1260cfb28ad349e2ce755 to your computer and use it in GitHub Desktop.
PHP Type Validator

PHP Type Validator Implementation Plan

Directory Structure

src/
├── Exception/
│   └── ValidationException.php
├── TypeValidatorVisitor.php
├── ValidationError.php
├── ValidationOptions.php
├── ValidationResult.php
└── Validator.php

Class Implementations

1. ValidationOptions

<?php

declare(strict_types=1);

namespace Typhoon\TypeValidator;

final class ValidationOptions
{
    public function __construct(
        public readonly bool $coerceTypes = false,
        public readonly bool $allowExtraKeys = false,
        public readonly int $maxDepth = 64,
    ) {}
    
    public static function default(): self
    {
        return new self();
    }
    
    public function withCoerceTypes(bool $coerceTypes): self
    {
        return new self(
            coerceTypes: $coerceTypes, 
            allowExtraKeys: $this->allowExtraKeys, 
            maxDepth: $this->maxDepth
        );
    }
    
    public function withAllowExtraKeys(bool $allowExtraKeys): self
    {
        return new self(
            coerceTypes: $this->coerceTypes, 
            allowExtraKeys: $allowExtraKeys, 
            maxDepth: $this->maxDepth
        );
    }
    
    public function withMaxDepth(int $maxDepth): self
    {
        return new self(
            coerceTypes: $this->coerceTypes, 
            allowExtraKeys: $this->allowExtraKeys, 
            maxDepth: $maxDepth
        );
    }
}

2. ValidationError

<?php

declare(strict_types=1);

namespace Typhoon\TypeValidator;

final class ValidationError
{
    public function __construct(
        private readonly string $path,
        private readonly mixed $value,
        private readonly string $expectedType,
        private readonly string $message,
    ) {}
    
    public function getPath(): string
    {
        return $this->path;
    }
    
    public function getValue(): mixed
    {
        return $this->value;
    }
    
    public function getExpectedType(): string
    {
        return $this->expectedType;
    }
    
    public function getMessage(): string
    {
        return $this->message;
    }
}

3. ValidationResult

<?php

declare(strict_types=1);

namespace Typhoon\TypeValidator;

final class ValidationResult
{
    /**
     * @param ValidationError[] $errors
     */
    public function __construct(
        private readonly bool $valid,
        private readonly array $errors = [],
    ) {}
    
    public static function valid(): self
    {
        return new self(true);
    }
    
    /**
     * @param ValidationError[] $errors
     */
    public static function invalid(array $errors): self
    {
        return new self(false, $errors);
    }
    
    public function isValid(): bool
    {
        return $this->valid;
    }
    
    /**
     * @return ValidationError[]
     */
    public function getErrors(): array
    {
        return $this->errors;
    }
    
    public function merge(self $other): self
    {
        if ($this->valid && $other->valid) {
            return self::valid();
        }
        
        return self::invalid([...$this->errors, ...$other->errors]);
    }
}

4. ValidationException

<?php

declare(strict_types=1);

namespace Typhoon\TypeValidator\Exception;

use Typhoon\TypeValidator\ValidationResult;

final class ValidationException extends \RuntimeException
{
    public function __construct(
        private readonly ValidationResult $result,
        string $message = 'Validation failed',
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        $errorMessages = array_map(
            fn($error) => $error->getMessage(),
            $result->getErrors()
        );
        
        $detailedMessage = $message . ":\n" . implode("\n", $errorMessages);
        
        parent::__construct($detailedMessage, $code, $previous);
    }
    
    public function getValidationResult(): ValidationResult
    {
        return $this->result;
    }
}

5. Validator

<?php

declare(strict_types=1);

namespace Typhoon\TypeValidator;

use Typhoon\Type\Type;
use Typhoon\TypeValidator\Exception\ValidationException;

final class Validator
{
    private ?ValidationResult $lastResult = null;
    private TypeValidatorVisitor $visitor;
    
    public function __construct(
        private readonly Type $type,
        private readonly ?ValidationOptions $options = null,
    ) {
        $this->visitor = new TypeValidatorVisitor($this->options ?? ValidationOptions::default());
    }
    
    public function isValid(mixed $value): bool
    {
        return $this->validate($value)->isValid();
    }
    
    public function validate(mixed $value): ValidationResult
    {
        $this->lastResult = $this->visitor->validate($this->type, $value);
        return $this->lastResult;
    }
    
    /**
     * @return ValidationError[]
     */
    public function getErrors(): array
    {
        return $this->lastResult?->getErrors() ?? [];
    }
    
    /**
     * @throws ValidationException
     */
    public function validateOrThrow(mixed $value): void
    {
        $result = $this->validate($value);
        
        if (!$result->isValid()) {
            throw new ValidationException($result);
        }
    }
}

6. TypeValidatorVisitor

This is the core of the validation system. It implements the TypeVisitor interface and contains methods for validating each type of type definition.

<?php

declare(strict_types=1);

namespace Typhoon\TypeValidator;

use Typhoon\Type\Type;
use Typhoon\Type\TypeVisitor;
use Typhoon\Type\Visitor\DefaultTypeVisitor;
use Typhoon\Type\types;

/**
 * @implements TypeVisitor<ValidationResult>
 */
final class TypeValidatorVisitor implements TypeVisitor
{
    private mixed $value;
    private string $path;
    private array $errors = [];
    private int $currentDepth = 0;
    
    public function __construct(
        private readonly ValidationOptions $options,
    ) {}
    
    public function validate(Type $type, mixed $value, string $path = 'value'): ValidationResult
    {
        $this->value = $value;
        $this->path = $path;
        $this->errors = [];
        $this->currentDepth = 0;
        
        $result = $type->accept($this);
        
        return $result->isValid() 
            ? ValidationResult::valid() 
            : ValidationResult::invalid($this->errors);
    }
    
    // Implement all the TypeVisitor methods...
    // This would be a very large class with methods for each type
    
    public function never(Type $type): mixed
    {
        $this->addError($type, 'Value can never be valid for never type');
        return ValidationResult::invalid($this->errors);
    }
    
    public function void(Type $type): mixed
    {
        $this->addError($type, 'void type can only be used as a return type');
        return ValidationResult::invalid($this->errors);
    }
    
    public function null(Type $type): mixed
    {
        if ($this->value === null) {
            return ValidationResult::valid();
        }
        
        if ($this->options->coerceTypes && $this->value === '') {
            $this->value = null;
            return ValidationResult::valid();
        }
        
        $this->addError($type, 'Value must be null');
        return ValidationResult::invalid($this->errors);
    }
    
    // And so on for all the other types...
    
    private function addError(Type $expectedType, string $message): void
    {
        $this->errors[] = new ValidationError(
            $this->path,
            $this->value,
            \Typhoon\Type\stringify($expectedType),
            sprintf(
                "Value at path '%s' %s, %s given.",
                $this->path,
                $message,
                $this->getTypeName($this->value)
            )
        );
    }
    
    private function getTypeName(mixed $value): string
    {
        return match (true) {
            $value === null => 'null',
            is_array($value) => 'array',
            is_object($value) => get_class($value),
            default => gettype($value),
        };
    }
}

Implementation Notes

  1. The TypeValidatorVisitor would need to implement all the methods from the TypeVisitor interface, which would make it quite large. Each method would validate a specific type of type definition.

  2. For complex validation (like object shapes, array shapes, etc.), the visitor would need to recurse into the structure, updating the path as it goes.

  3. We'd need to handle the maxDepth option to prevent infinite recursion for recursive types.

  4. Type coercion (when coerceTypes is true) would need special handling for each type.

  5. For union types, we'd need to try validating against each type in the union until one succeeds.

  6. For intersection types, we'd need to validate against all types in the intersection.

  7. The visitor should maintain path information to provide clear error messages about where validation failed.

  8. We might need additional helper methods for specific validations, like validating callables, checking array shapes, etc.

The most complex part of this implementation would be the TypeValidatorVisitor class, which would need to handle all the different types in the type system correctly. The other classes are relatively straightforward.

PHP Type Validator

A runtime type validation library for PHP that leverages the Typhoon Type System to validate data structures against type definitions.

Features

  • Validate any PHP value against sophisticated type definitions
  • Detailed validation errors with paths to invalid values
  • Support for all Typhoon Type System types including:
    • Primitive types (int, string, bool, etc.)
    • Composite types (array, list, iterable)
    • Complex types (object shapes, union types, intersection types)
    • Conditional types and template types
  • Fluent API for validation configuration
  • Zero dependencies outside of the Typhoon Type System

Installation

composer require typhoon/type-validator

Quick Start

use Typhoon\Type\types;
use Typhoon\TypeValidator\Validator;

// Define a type
$userType = types::arrayShape([
    'id' => types::positiveInt,
    'name' => types::nonEmptyString,
    'email' => types::optional(types::string),
    'roles' => types::list(types::string)
]);

// Create a validator
$validator = new Validator($userType);

// Validate data
$userData = [
    'id' => 123,
    'name' => 'John Doe',
    'email' => '[email protected]',
    'roles' => ['user', 'admin']
];

if ($validator->isValid($userData)) {
    // Data is valid
    processUser($userData);
} else {
    // Data is invalid
    $errors = $validator->getErrors();
    foreach ($errors as $error) {
        echo $error->getMessage() . "\n";
    }
}

Class Diagram

classDiagram
    class Validator {
        -Type type
        -ValidationOptions options
        +__construct(Type type, ?ValidationOptions options)
        +isValid(mixed value): bool
        +validate(mixed value): ValidationResult
        +getErrors(): array~ValidationError~
    }
    
    class ValidationResult {
        -bool valid
        -array~ValidationError~ errors
        +isValid(): bool
        +getErrors(): array~ValidationError~
    }
    
    class ValidationError {
        -string path
        -mixed value
        -string expectedType
        -string message
        +getPath(): string
        +getValue(): mixed
        +getExpectedType(): string
        +getMessage(): string
    }
    
    class ValidationOptions {
        -bool coerceTypes
        -bool allowExtraKeys
        -int maxDepth
        +__construct(bool coerceTypes, bool allowExtraKeys, int maxDepth)
        +withCoerceTypes(bool value): self
        +withAllowExtraKeys(bool value): self
        +withMaxDepth(int value): self
    }
    
    class TypeValidatorVisitor {
        -mixed value
        -string path
        -ValidationOptions options
        -array~ValidationError~ errors
        +validate(Type type, mixed value, string path): ValidationResult
    }
    
    Validator --> ValidationResult : creates
    Validator --> TypeValidatorVisitor : uses
    ValidationResult --> ValidationError : contains
    Validator --> ValidationOptions : uses
    TypeValidatorVisitor --> ValidationResult : creates
    TypeValidatorVisitor --> ValidationError : creates
Loading

Detailed Usage

Creating Validators

// Basic usage
$validator = new Validator($type);

// With options
$validator = new Validator(
    $type,
    new ValidationOptions(
        coerceTypes: true,
        allowExtraKeys: false,
        maxDepth: 10
    )
);

// Or using fluent configuration
$validator = new Validator(
    $type,
    ValidationOptions::default()
        ->withCoerceTypes(true)
        ->withAllowExtraKeys(false)
);

Validation Options

The ValidationOptions class allows you to customize validation behavior:

  • coerceTypes: Attempt to coerce values to the expected type (e.g., string → int)
  • allowExtraKeys: Allow extra keys in array shapes or object shapes
  • maxDepth: Maximum nesting depth for validation (prevents infinite recursion)

Validating Data

// Check if data is valid (returns boolean)
if ($validator->isValid($data)) {
    // ...
}

// Full validation with detailed results
$result = $validator->validate($data);
if ($result->isValid()) {
    // ...
} else {
    $errors = $result->getErrors();
    // ...
}

// Get errors from the last validation
$errors = $validator->getErrors();

Validation Errors

The ValidationError class provides detailed information about validation failures:

foreach ($validator->getErrors() as $error) {
    echo "Path: " . $error->getPath() . "\n";
    echo "Value: " . var_export($error->getValue(), true) . "\n";
    echo "Expected: " . $error->getExpectedType() . "\n";
    echo "Message: " . $error->getMessage() . "\n";
    echo "---\n";
}

Example output:

Path: user.id
Value: "abc"
Expected: positive-int
Message: Value at path 'user.id' must be a positive integer, string given.
---
Path: user.roles[2]
Value: 123
Expected: string
Message: Value at path 'user.roles[2]' must be a string, integer given.
---

Exception Handling

For a more exception-based approach:

use Typhoon\TypeValidator\Exception\ValidationException;

try {
    $validator->validateOrThrow($data);
    // Data is valid
} catch (ValidationException $e) {
    $result = $e->getValidationResult();
    $errors = $result->getErrors();
    // Handle validation failure
}

Supported Types

The validator supports all types from the Typhoon Type System:

Primitive Types

// Boolean types
$boolType = types::bool;
$trueType = types::true;
$falseType = types::false;

// Integer types
$intType = types::int;
$positiveIntType = types::positiveInt;
$negativeIntType = types::negativeInt;
$nonPositiveIntType = types::nonPositiveInt;
$nonNegativeIntType = types::nonNegativeInt;
$intRangeType = types::intRange(1, 100);
$literalIntType = types::int(42);

// Float types
$floatType = types::float;
$floatRangeType = types::floatRange(0.0, 1.0);
$literalFloatType = types::float(3.14);

// String types
$stringType = types::string;
$nonEmptyStringType = types::nonEmptyString;
$numericStringType = types::numericString;
$literalStringType = types::string("hello");

// Other primitive types
$nullType = types::null;
$resourceType = types::resource;
$mixedType = types::mixed;

Composite Types

// Array types
$arrayType = types::array();
$keyValueArrayType = types::array(types::int, types::string);
$nonEmptyArrayType = types::nonEmptyArray();

// Array shapes
$userArrayType = types::arrayShape([
    'id' => types::positiveInt,
    'name' => types::nonEmptyString,
    'email' => types::optional(types::string),
]);

// List types
$listType = types::list();
$stringListType = types::list(types::string);
$nonEmptyListType = types::nonEmptyList(types::int);

// List shapes
$coordinatesType = types::listShape([
    types::float, // x
    types::float, // y
    types::optional(types::float), // z (optional)
]);

// Object types
$objectType = types::object;
$dateTimeType = types::object(\DateTime::class);

// Object shapes
$pointType = types::objectShape([
    'x' => types::float,
    'y' => types::float,
    'distanceFrom' => types::callable([types::param(types::float), types::param(types::float)], types::float),
]);

// Callable types
$callableType = types::callable();
$specificCallableType = types::callable(
    [types::param(types::string), types::param(types::int, hasDefault: true)],
    types::bool
);

Advanced Types

// Union types
$idType = types::union(types::positiveInt, types::string);

// Intersection types
$nonEmptyNumericStringType = types::intersection(types::nonEmptyString, types::numericString);

// Conditional types
$numberOrStringType = types::conditional(
    types::template(/* ... */),
    types::int,
    types::int,
    types::string
);

// Template types
$genericType = types::classTemplate(Collection::class, 'T');

// Class-string types
$classStringType = types::classString(types::object(\DateTimeInterface::class));

Error Messages

The validator provides clear, descriptive error messages:

• Value at path 'user.id' must be a positive integer, string given.
• Value at path 'user.metadata.lastLogin' is not a valid date string.
• Value at path 'user.roles' must be a non-empty list, empty array given.
• Value at path 'config' must be an array with required keys: 'version', 'debug'.
• Value at path 'callback' must be a callable accepting (string, int) and returning bool.

Performance Considerations

  • For performance-critical applications, consider validating data only at system boundaries
  • Use the maxDepth option to prevent validation of deeply nested structures
  • Enable the coerceTypes option only when necessary, as it adds overhead

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This library is licensed under the MIT License - see the LICENSE file for details.

sequenceDiagram
    participant Client
    participant Validator
    participant TypeValidatorVisitor
    participant TypeImpl as Type Implementation
    participant ValidationResult
    participant ValidationError

    Client->>Validator: new Validator(types::arrayShape(...))
    Validator->>TypeValidatorVisitor: new TypeValidatorVisitor(options)
    
    Client->>Validator: validate(data)
    Validator->>TypeValidatorVisitor: validate(type, data, "value")
    
    TypeValidatorVisitor->>TypeImpl: type->accept(visitor)
    
    alt Array Type
        TypeImpl->>TypeValidatorVisitor: visitor->array(this, keyType, valueType, elements)
        TypeValidatorVisitor->>TypeValidatorVisitor: validateArray(type, value, path)
        
        loop For each array element
            TypeValidatorVisitor->>TypeValidatorVisitor: buildPath(path, key)
            TypeValidatorVisitor->>TypeValidatorVisitor: validateValue(elementType, element, elementPath)
            TypeValidatorVisitor->>TypeImpl: elementType->accept(visitor)
            TypeImpl-->>TypeValidatorVisitor: Result
        end
    else Union Type
        TypeImpl->>TypeValidatorVisitor: visitor->union(this, types)
        
        loop For each union type
            TypeValidatorVisitor->>TypeValidatorVisitor: validateValue(unionType, value, path)
            TypeValidatorVisitor->>TypeImpl: unionType->accept(visitor)
            TypeImpl-->>TypeValidatorVisitor: Result
            
            alt Valid Result
                TypeValidatorVisitor-->>TypeValidatorVisitor: Break loop and return valid result
            end
        end
    else Primitive Type
        TypeImpl->>TypeValidatorVisitor: visitor->int/string/bool/etc(this, ...)
        TypeValidatorVisitor->>TypeValidatorVisitor: Check value against type constraints
        
        alt Invalid Value
            TypeValidatorVisitor->>ValidationError: new ValidationError(path, value, expectedType, message)
        end
    end
    
    TypeImpl-->>TypeValidatorVisitor: ValidationResult
    
    alt Invalid Result
        TypeValidatorVisitor->>ValidationResult: ValidationResult::invalid(errors)
    else Valid Result
        TypeValidatorVisitor->>ValidationResult: ValidationResult::valid()
    end
    
    TypeValidatorVisitor-->>Validator: ValidationResult
    Validator-->>Client: ValidationResult
    
    alt Result is invalid
        Client->>ValidationResult: getErrors()
        ValidationResult-->>Client: ValidationError[]
        
        loop For each error
            Client->>ValidationError: getMessage()
            ValidationError-->>Client: Error message
        end
    end
Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment