Search by

stryxx / strict-types

stryxx

A lightweight library that helps enforce and maintain predictable types in PHP 8.1+ applications.

Package info

github.com/stryxx/strict-types

pkg:composer/stryxx/strict-types

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-07-20 08:15 UTC

This package is auto-updated.

Last update: 2026-08-20 08:41:39 UTC


README

Latest Version PHP Version License

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:

  • Type checks values and returns them without changing them,
  • Cast converts 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.