stryxx / strict-types
A lightweight library that helps enforce and maintain predictable types in PHP 8.1+ applications.
Requires
- php: ^8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0 || ^13.0
- rector/rector: ^2.5.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A small PHP library for checking values and making clear conversions at runtime.
This package does not replace or change PHP's declare(strict_types=1).
It is useful when a value comes from a place with a broad return type, for example:
- Doctrine DBAL,
- an external API or SDK,
- old or dynamic code,
- JSON, cache, configuration, or plugin data,
- any method that returns
mixed,object,array<mixed>, or another broad type.
The main API has two classes:
Typechecks values and returns them without changing them,Castconverts values using documented rules.
The optional Resolver<T> and TargetTypeProvider<T> contracts carry object types through generic code. They are tested with PHPStan and can also improve type hints and code completion in PhpStorm.
Installation
composer require stryxx/strict-types
Basic use
use Stryxx\StrictTypes\Cast; use Stryxx\StrictTypes\Type; $name = Type::nonEmptyString($data['name'] ?? null); $userId = Cast::int($data['id'] ?? null);
Both return the expected type. Wrong values cause an exception.
Type
Type checks that a value already has the expected type. It does not convert it.
$name = Type::string($value); $id = Type::positiveInt($value); $items = Type::list($value); $user = Type::instanceOf($value, User::class);
Wrong values are rejected:
Type::string(123); // throws InvalidType Type::float(1); // throws InvalidType Type::positiveInt(0); // throws InvalidType Type::array(false); // throws InvalidType
Collection helpers also keep the original values. Their callback must check and return the same value:
$ids = Type::listOf( [1, 2, 3], Type::int(...), );
This is not a conversion helper:
Type::listOf(['1', '2'], Cast::int(...)); // throws
Use Cast::listOf() when items must be converted.
Cast
Cast converts a value only when it matches a documented rule. It rejects partial values, non-finite floats, and values that do not match a clear conversion rule.
Cast::string(123); // '123' Cast::int('123'); // 123 Cast::float('12.5'); // 12.5 Cast::bool('false'); // false
Unsafe or unclear conversions are rejected:
Cast::string(true); // throws InvalidCast Cast::int('12 items'); // throws InvalidCast Cast::int(12.5); // throws InvalidCast Cast::bool('yes'); // throws InvalidCast
For booleans, the library accepts only the values listed below. It does not use PHP's automatic boolean conversion.
Conversion rules
| Method | Accepted values |
|---|---|
Cast::string() |
strings, integers, finite floats, and Stringable objects |
Cast::int() |
integers, decimal integer strings, and finite whole floats inside the PHP integer range |
Cast::float() |
finite floats, integers that can be converted to float and back without changing the integer, and finite numeric strings |
Cast::bool() |
booleans, integers 0 and 1, and the strings 0, 1, true, and false |
Whitespace, a sign, and leading zeroes are allowed in integer strings:
Cast::int(' 123 '); // 123 Cast::int('+123'); // 123 Cast::int('00123'); // 123
Decimal and exponent strings are not integers:
Cast::int('12.0'); // throws InvalidCast Cast::int('1e3'); // throws InvalidCast Cast::float('1e3'); // 1000.0
Boolean strings are trimmed and are not case-sensitive:
Cast::bool(' FALSE '); // false Cast::bool('1'); // true Cast::bool(''); // throws InvalidCast
A finite float is any float other than INF, -INF, or NAN. These special values can appear after an overflow or an invalid math operation.
Float conversions use normal PHP float behavior and precision.
Doctrine DBAL examples
Doctrine DBAL uses broad return types because the exact PHP value can depend on the query and database driver.
The Doctrine\DBAL\Result class has methods such as:
fetchOne(): mixed,fetchAssociative(): array<string, mixed>|false,fetchFirstColumn(): list<mixed>.
fetchOne() returns the first value from the next row. It returns false when there are no more rows.
$result = $connection->executeQuery( 'SELECT id FROM users WHERE email = :email', ['email' => $email], ); $value = $result->fetchOne(); $userId = false === $value ? null : Cast::int($value);
For a string column, verify the value instead of casting it:
$result = $connection->executeQuery( 'SELECT uuid FROM users WHERE email = :email', ['email' => $email], ); $value = $result->fetchOne(); $userUuid = false === $value ? null : Type::string($value);
When a row must exist, handle false before converting the value:
$result = $connection->executeQuery('SELECT COUNT(*) FROM users'); $value = $result->fetchOne(); if (false === $value) { throw new UnexpectedValueException('Count query returned no row.'); } $count = Cast::int($value);
fetchAssociative() already tells you that a row can be missing:
$result = $connection->executeQuery( 'SELECT id, name FROM users WHERE email = :email', ['email' => $email], ); $row = $result->fetchAssociative(); if (false === $row) { return null; } $user = new User( Cast::int($row['id'] ?? null), Type::nonEmptyString($row['name'] ?? null), );
fetchFirstColumn() returns a list, but every item is still mixed:
$result = $connection->executeQuery('SELECT id FROM users'); $ids = Cast::listOf( $result->fetchFirstColumn(), Cast::int(...), );
The application decides what a missing row means. The library only checks or converts the value after that decision.
Legacy and vendor code
The whole project does not need to be strict. You can add a checked boundary only around new code.
function legacyOption(string $name): mixed { return get_option($name); } $enabled = Cast::bool(legacyOption('search_enabled')); $limit = Type::positiveInt( Cast::int(legacyOption('result_limit')), );
After this point, the new code receives a real bool and a positive int.
The same idea works with API responses:
$data = Type::array($response->json()); $id = Cast::int($data['id'] ?? null); $name = Type::nonEmptyString($data['name'] ?? null);
Arrays and lists
Type::arrayOf() and Type::listOf() check every item and keep the original values.
$ids = Type::listOf( $value, Type::int(...), ); $usersById = Type::arrayOf( $value, static fn(mixed $item): User => Type::instanceOf($item, User::class), );
arrayOf() keeps the original keys. listOf() requires a list with sequential keys.
Use the Cast versions when the callback should change the values:
$ids = Cast::listOf(['1', '2', '3'], Cast::int(...)); $usersById = Cast::arrayOf( $data, static fn(mixed $item): User => $resolver->resolve($item), );
The second example uses a resolver as the collection callback. See Resolvers.
Cast::listOf() can also read a comma-separated string. The separator can be changed:
$ids = Cast::listOf('1, 2, 3', Cast::int(...)); $roles = Cast::listOf( 'admin; editor', Type::nonEmptyString(...), separator: ';', );
An empty source string becomes an empty list. Empty items such as 1,,2 are rejected.
Objects and enums
You can verify one object or a list of objects:
$user = Type::instanceOf($value, User::class); $users = Type::instancesOf($value, User::class);
You can also convert backed enum values:
$status = Cast::enum('ready', Status::class);
Resolvers
Resolver<T> is useful when one class maps unknown data into a known result type.
use Stryxx\StrictTypes\Contract\Resolver; /** * @implements Resolver<User> */ final class UserResolver implements Resolver { public function resolve(mixed $value): User { $data = Type::array($value); return new User( Cast::int($data['id'] ?? null), Type::nonEmptyString($data['name'] ?? null), ); } }
Use the resolver directly:
$user = (new UserResolver())->resolve($payload);
Because resolve() has a native User return type, PHPStan and PhpStorm know that $user is a User. The Resolver<User> annotation keeps this type when the resolver is passed through generic code.
Resolvers can also be used with collection casts:
$users = Cast::listOf( $payloads, $resolver->resolve(...), );
Target type providers
TargetTypeProvider<T> connects an object with its expected result type.
use Stryxx\StrictTypes\Contract\TargetTypeProvider; /** * @implements TargetTypeProvider<UserResponse> */ final class UserRequest implements TargetTypeProvider { public static function targetType(): string { return UserResponse::class; } }
A serializer or factory can use the provided class. Type::targetOf() then verifies the returned object:
$request = new UserRequest(); $user = Type::targetOf( $serializer->deserialize( $payload, $request::targetType(), ), $request, );
For a direct class name, use Type::instanceOf():
$user = Type::instanceOf( $factory->create(User::class), User::class, );
Nullable values
Use nullable() when null is allowed and there is no dedicated nullable helper for the target type.
For example, Type::nullable() can verify a nullable object:
$user = Type::nullable( $value, static fn(mixed $value): User => Type::instanceOf( $value, User::class, ), );
Cast::nullable() can convert a nullable value into a custom type:
$status = Cast::nullable( $value, static fn(mixed $value): Status => Cast::enum( $value, Status::class, ), );
For common scalar types, use helpers such as nullableString(), nullableInt(), nullableFloat(), or nullableBool().
Exceptions
The package uses two main exceptions:
use Stryxx\StrictTypes\Exception\InvalidCast; use Stryxx\StrictTypes\Exception\InvalidType;
Both implement StrictTypesException:
use Stryxx\StrictTypes\Exception\StrictTypesException; try { $id = Cast::int($value); } catch (StrictTypesException $exception) { // Handle InvalidType and InvalidCast from this package. }
The package does not wrap exceptions thrown by your callbacks or resolvers.
Passing a callback that changes a value to a Type collection or nullable helper is an invalid argument.
Static analysis
Static analysis is optional. The package works at runtime without PHPStan.
PHP enforces native return types. Refined and generic PHPDoc types are tested with PHPStan. PhpStorm can also use many of them for type hints and code completion, but its support is not identical to PHPStan.
PHPStan can understand types such as:
int
positive-int
non-empty-string
list<User>
class-string<User>
When a package method promises a refined PHPDoc type, its runtime check matches that promise.
Requirements
- PHP 8.1 or newer
PHP 8.1 support is kept mainly for older projects. For new projects, use a currently supported PHP version.
Examples
The examples/ directory contains runnable examples.
Run all of them with:
composer examples
Development
Install development dependencies:
composer install
Run all checks:
composer check
Apply automatic fixes:
composer fix
License
MIT. See LICENSE.