Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion docs/supported-types/primitives-and-scalars.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,63 @@ To prevent rounding artifacts from causing unexpected type failures, TypePHP eva

---

## Wildcard Constant Patterns (`Class::PREFIX_*` & `StatusEnum::*`)

TypePHP supports validating parameters, return types, properties, and `@var` local assignments against wildcard constant patterns (`Class::PREFIX_*` or `StatusEnum::*`).

* **Class Constants (`Class::PREFIX_*`):** TypePHP uses Reflection to safely inspect all matching constants—including `public`, `protected`, and `private` class constants—and validates that the incoming value matches one of the declared constant scalar values.
* **Enums (`StatusEnum::*` or `StatusEnum::ACT*`):** Matches incoming **Enum case objects** against the wildcard case pattern.

```php
class MigrationCollectionLoader
{
public const VERSION_SELECTION_ALL = 'all';
public const VERSION_SELECTION_BLUE_GREEN = 'blue-green';
private const VERSION_SELECTION_INTERNAL = 'internal-mode';

/**
* @param self::VERSION_SELECTION_* $mode
*/
public function collectAllForVersion(string $mode): string
{
return $mode;
}
}

enum StatusEnum: string
{
case Active = 'active';
case Inactive = 'inactive';
case Pending = 'pending';
}

class EnumProcessor
{
/**
* @param StatusEnum::ACT* $status
*/
public static function processActive(StatusEnum $status): StatusEnum
{
return $status;
}
}

$loader = new MigrationCollectionLoader();

// Valid Class Constant Calls (Matches public and private VERSION_SELECTION_* constant values)
$loader->collectAllForVersion('all');
$loader->collectAllForVersion('internal-mode');

// Valid Enum Wildcard Call (Matches StatusEnum::ACT* -> StatusEnum::Active)
EnumProcessor::processActive(StatusEnum::Active);

// Invalid Enum Wildcard Call (StatusEnum::Pending does not match StatusEnum::ACT*)
EnumProcessor::processActive(StatusEnum::Pending);
// Throws: TypeError: Argument $status must be a valid constant matching StatusEnum::ACT*
```

---

## Integer Refinements and Ranges

TypePHP enforces exact value constraints and bounds on integer parameters:
Expand Down Expand Up @@ -358,4 +415,4 @@ function badHaltExecution(): string

badHaltExecution();
// Throws: TypeError: badHaltExecution(): Return value must be of type never
```
```
2 changes: 1 addition & 1 deletion src/Validator/ArrayShapeValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali

return null;
}
}
}
60 changes: 56 additions & 4 deletions src/Validator/ConstValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@
use TypePHP\Internal\TypeFormatter;

/**
* @internal Validates literal values, class constants, and PHP 8.1 Enum cases against ConstTypeNode ASTs.
* @internal Validates literal values, class constants, and wildcard constant patterns (Class::PREFIX_*) against ConstTypeNode ASTs.
*/
final class ConstValidator implements TypeValidatorInterface
{
/**
* @var array<string, array<int, mixed>>
*/
private static array $wildcardConstantCache = [];

public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage
{
/** @var ConstTypeNode $constTypeNode */
Expand All @@ -41,9 +46,24 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
} elseif ($constExpr instanceof ConstExprFloatNode) {
$expected = (float) $constExpr->value;
} elseif ($constExpr instanceof ConstFetchNode) {
$fqcnConstant = $constExpr->className !== ''
? $constExpr->className . '::' . $constExpr->name
: $constExpr->name;
$className = $constExpr->className;
$pattern = $constExpr->name;

if (str_contains($pattern, '*')) {
$allowedValues = self::resolveWildcardConstantValues($className, $pattern);

if (! \in_array($value, $allowedValues, true)) {
$fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern;

return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value) . ' given');
}

return null;
}

$fqcnConstant = $className !== ''
? $className . '::' . $pattern
: $pattern;

if (\defined($fqcnConstant)) {
$expected = \constant($fqcnConstant);
Expand All @@ -69,4 +89,36 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali

return null;
}

/**
* Resolves and caches all values of class constants matching a wildcard pattern (e.g. PREFIX_*).
*
* @return array<int, mixed>
*/
private static function resolveWildcardConstantValues(string $className, string $pattern): array
{
$cacheKey = $className . '::' . $pattern;
if (isset(self::$wildcardConstantCache[$cacheKey])) {
return self::$wildcardConstantCache[$cacheKey];
}

$values = [];

if ($className !== '' && (class_exists($className) || interface_exists($className))) {
try {
$refClass = new \ReflectionClass($className);
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i';

foreach ($refClass->getConstants() as $cName => $cValue) {
if (preg_match($regex, $cName) === 1) {
$values[] = $cValue;
}
}
} catch (\ReflectionException $e) {
// Silently ignore reflection errors
}
}

return self::$wildcardConstantCache[$cacheKey] = $values;
}
}
24 changes: 24 additions & 0 deletions tests/Fixtures/Types/EnumWildcard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Types;

class EnumWildcard
{
/**
* @param StatusEnum::* $status
*/
public static function processEnumCase(StatusEnum $status): StatusEnum
{
return $status;
}

/**
* @param StatusEnum::ACT* $status
*/
public static function processPrefixEnumCase(StatusEnum $status): StatusEnum
{
return $status;
}
}
2 changes: 1 addition & 1 deletion tests/Fixtures/Types/GlobalTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
*/
class GlobalTypes
{
}
}
20 changes: 20 additions & 0 deletions tests/Fixtures/Types/WildcardConstantFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Types;

class WildcardConstantFixture
{
public const VERSION_SELECTION_ALL = 'all';
public const VERSION_SELECTION_BLUE_GREEN = 'blue-green';
private const VERSION_SELECTION_INTERNAL = 'internal-mode';

/**
* @param self::VERSION_SELECTION_* $mode
*/
public static function setVersionMode(string $mode): string
{
return $mode;
}
}
4 changes: 2 additions & 2 deletions tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ function testKeylessImplicitTupleShape(array $tuple): bool
*
* @phpstan-type LocalTupleAlias array{list<positive-int>, list<non-empty-string>}
* @phpstan-type MixedTupleShape array{non-empty-string, code: positive-int, list<int>}
*
* @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple
*
* @param LocalTupleAlias $payload
Expand Down Expand Up @@ -153,7 +154,6 @@ function testReturnKeylessTuple(bool $valid): array
return [[10, 20], 'bundle'];
}


describe('Class Object Arrays (Dog[])', function () {
test('accepts array of matching class instances', function () {
expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2);
Expand Down Expand Up @@ -379,4 +379,4 @@ function testReturnKeylessTuple(bool $valid): array
->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int")
;
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public function __construct()

test('falls back gracefully to generic union error when no deep branch matches structure', function () {
expect(fn () => testDeepUnionError('string_instead_of_array'))
->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)');
->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)')
;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed
expect(testInferredOverridesDefault($dt, $dt))->toBe($dt);

expect(fn () => testInferredOverridesDefault($dt, new stdClass()))
->toThrow(TypeError::class, 'Return value');
->toThrow(TypeError::class, 'Return value')
;
});
});
33 changes: 33 additions & 0 deletions tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

declare(strict_types=1);

use TypePHP\Tests\Fixtures\Types\EnumWildcard;
use TypePHP\Tests\Fixtures\Types\StatusEnum;
use TypePHP\Tests\Fixtures\Types\WildcardConstantFixture;

/**
* 1. Integer Extended Types
*
Expand Down Expand Up @@ -140,3 +144,32 @@ function testLiteralUnionsParam(string $status, int $code): bool
;
});
});

describe('Wildcard Constant Pattern Validation (self::PREFIX_*)', function () {
test('accepts values matching wildcard constant patterns (including private constants)', function () {
expect(WildcardConstantFixture::setVersionMode('all'))->toBe('all');
expect(WildcardConstantFixture::setVersionMode('blue-green'))->toBe('blue-green');
expect(WildcardConstantFixture::setVersionMode('internal-mode'))->toBe('internal-mode');
});

test('throws TypeError on value not matching wildcard constant pattern', function () {
expect(fn () => WildcardConstantFixture::setVersionMode('invalid_mode'))
->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\WildcardConstantFixture::VERSION_SELECTION_*')
;
});
});

describe('Enum Wildcard Pattern Validation (StatusEnum::*)', function () {
test('accepts Enum case objects matching wildcard patterns (StatusEnum::* & StatusEnum::ACT*)', function () {
expect(EnumWildcard::processEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active);
expect(EnumWildcard::processEnumCase(StatusEnum::Pending))->toBe(StatusEnum::Pending);

expect(EnumWildcard::processPrefixEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active);
});

test('throws TypeError when Enum case object does not match prefix wildcard (e.g. Pending for StatusEnum::ACT*)', function () {
expect(fn () => EnumWildcard::processPrefixEnumCase(StatusEnum::Pending))
->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\StatusEnum::ACT*')
;
});
});
Loading