joby/smol-cast

Predictable type coercion from mixed inputs with explicit error handling.

Installs: 53

Dependents: 4

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/joby/smol-cast

v1.3.0 2026-01-26 00:22 UTC

This package is auto-updated.

Last update: 2026-01-26 00:24:05 UTC


README

Predictable type coercion from mixed input with explicit error handling.

Installation

composer require joby-lol/smol-cast

About

smolCast provides type-safe conversion methods for scalar values with permissive coercion rules and clear error handling. Designed for parsing user input, query strings, and decoded data where types may not match expectations.

Key features:

  • Permissive coercion: Accepts multiple input types for each target type
  • Explicit errors: Throws exceptions instead of silent failures
  • Null-safe: Returns null for null input, throws for unconvertible values
  • No dependencies: Pure PHP with no external requirements

Basic Usage

use Joby\Smol\Cast\Cast;

// Returns null for null input
Cast::int(null);    // null
Cast::float(null);  // null
Cast::bool(null);   // null
Cast::string(null); // null

// Converts compatible types
Cast::int("42");     // 42
Cast::float("3.14"); // 3.14
Cast::bool("yes");   // true
Cast::string(123);   // "123"

// Throws TypeCastException for incompatible types
Cast::int("not a number"); // throws
Cast::bool("maybe");       // throws

Type Conversion Rules

int()

Converts to integer with the following rules:

  • int: Returned as-is
  • bool: true → 1, false → 0
  • float: Converted if whole number within int range (123.0 → 123, 123.5 throws)
  • string/Stringable: Parsed if numeric (whitespace trimmed, "-42" and "3.0" work)
Cast::int(42);        // 42
Cast::int(true);      // 1
Cast::int(3.0);       // 3
Cast::int("  -42  "); // -42
Cast::int(3.14);      // throws (fractional part)
Cast::int("abc");     // throws (not numeric)

float()

Converts to float with the following rules:

  • float: Returned as-is
  • int: Converted to float
  • bool: true → 1.0, false → 0.0
  • string/Stringable: Parsed if numeric (whitespace trimmed)
Cast::float(3.14);      // 3.14
Cast::float(42);        // 42.0
Cast::float(true);      // 1.0
Cast::float("  2.5  "); // 2.5
Cast::float("abc");     // throws (not numeric)

bool()

Converts to boolean with the following rules:

  • bool: Returned as-is
  • int: 1 → true, 0 → false (other values throw)
  • float: 1.0 → true, 0.0 → false (other values throw)
  • string/Stringable: Parsed case-insensitively (whitespace trimmed):
    • True: "1", "true", "yes", "on"
    • False: "0", "false", "no", "off", "" (empty string)
Cast::bool(true);    // true
Cast::bool(1);       // true
Cast::bool("yes");   // true
Cast::bool("YES");   // true (case-insensitive)
Cast::bool(0);       // false
Cast::bool("");      // false (empty string)
Cast::bool("   ");   // false (whitespace-only)
Cast::bool("maybe"); // throws (invalid string)
Cast::bool(2);       // throws (invalid int)

string()

Converts to string with the following rules:

  • string: Returned as-is
  • bool: true → "1", false → "" (empty string)
  • int/float: Standard string conversion
  • Stringable: Calls __toString()
Cast::string("hello"); // "hello"
Cast::string(true);    // "1"
Cast::string(false);   // ""
Cast::string(42);      // "42"
Cast::string(3.14);    // "3.14"
Cast::string([1,2,3]); // throws (not scalar)

Usage Patterns

Query String Parsing

// $_GET['page'] might be string "5" or missing
$page = Cast::int($_GET['page'] ?? null) ?? 1;

// $_GET['debug'] might be "1", "true", "yes", etc.
$debug = Cast::bool($_GET['debug'] ?? null) ?? false;

Decoded JSON/API Data

$data = json_decode($response, true);

// Data types might not match expectations
$userId = Cast::int($data['user_id'] ?? null);
$isActive = Cast::bool($data['active'] ?? null);
$price = Cast::float($data['price'] ?? null);

Database Result Handling

// Some DB drivers return all values as strings
$row = $pdo->fetch(PDO::FETCH_ASSOC);

$id = Cast::int($row['id']);            // "42" → 42
$amount = Cast::float($row['amount']);  // "19.99" → 19.99
$enabled = Cast::bool($row['enabled']); // "1" → true

Form Input Validation

try {
    $age = Cast::int($_POST['age']);
    $email = Cast::string($_POST['email']);
    $subscribe = Cast::bool($_POST['subscribe'] ?? null) ?? false;
    
    // Process validated data
} catch (TypeCastException $e) {
    // Handle invalid input
}

Exceptions

All methods throw TypeCastException (extends Exception) when a value cannot be converted to the target type. Null input always returns null and never throws.

use Joby\Smol\Cast\TypeCastException;

try {
    $value = Cast::int($_GET['count']);
} catch (TypeCastException $e) {
    // Handle conversion error
    $value = 1; // fallback
}

CastingGettersTrait

The CastingGettersTrait provides a convenient way to add type-safe getters to any class that stores keyed values. Perfect for configuration objects, request wrappers, or any class that needs to convert raw data to specific types. There is also a static version that works the same but with static methods: StaticCastingGettersTrait.

Basic Usage

use Joby\Smol\Cast\CastingGettersTrait;
use Joby\Smol\Cast\TypeCastException;

class UserInput
{
    use CastingGettersTrait;
    
    public function __construct(private array $data) {}
    
    protected function getCastableValue(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }
    
    protected function createRequiredException(string $type, string $name): \Throwable
    {
        return new TypeCastException("Required $type property '$name' is null");
    }
    
    protected function createCastException(string $type, string $name, \Throwable $previous): \Throwable
    {
        return new TypeCastException("Cannot cast property '$name' to $type", 0, $previous);
    }
}
$input = new UserInput([
    'user_id' => '123',
    'age' => '25',
    'is_active' => 'yes',
    'email' => 'user@example.com',
]);
$userId = $input->getInt('user_id');      // 123
$age = $input->getInt('age');             // 25
$isActive = $input->getBool('is_active'); // true
$email = $input->getString('email');      // "user@example.com"

Available Methods

The trait provides two variants for each type: get() methods* - Return typed value or null:

  • getInt(string $name, ?int $default = null): ?int
  • getFloat(string $name, ?float $default = null): ?float
  • getBool(string $name, ?bool $default = null): ?bool
  • getString(string $name, ?string $default = null): ?string require() methods* - Return typed value or throw:
  • requireInt(string $name): int
  • requireFloat(string $name): float
  • requireBool(string $name): bool
  • requireString(string $name): string

get*() vs require*()

The key difference is null handling:

// get*() returns null for missing/null values
$optional = $input->getInt('optional_field');  // null
// require*() throws exception for missing/null values
try {
    $required = $input->requireInt('missing_field');
} catch (TypeCastException $e) {
    // "Required int property 'missing_field' is null"
}

Both variants throw exceptions if the value exists but cannot be cast to the target type.

Default Values

All get*() methods accept an optional default value:

// Use default when value is null or missing
$limit = $input->getInt('limit', 10);           // 10 if 'limit' not set
$timeout = $input->getFloat('timeout', 30.0);   // 30.0 if 'timeout' not set
$debug = $input->getBool('debug', false);       // false if 'debug' not set
$theme = $input->getString('theme', 'default'); // 'default' if 'theme' not set
// Default is ignored when value exists (even if zero/false/empty)
$count = $input->getInt('count', 99);  // 0 if 'count' is 0, not 99
$flag = $input->getBool('flag', true); // false if 'flag' is false, not true

Important: Defaults are NOT cast - they must already be the correct type. If a value exists but cannot be cast, an exception is thrown even when a default is provided.

Implementation Requirements

Classes using the trait must implement three abstract methods:

abstract protected function getCastableValue(string $name): mixed;
abstract protected function createRequiredException(string $type, string $name): \Throwable;
abstract protected function createCastException(string $type, string $name, \Throwable $previous): \Throwable;

getCastableValue() - Returns the raw value for the given property name, or null if missing.

createRequiredException() - Creates the exception thrown by require*() methods when a value is null/missing. Receives the requested type name and property name.

createCastException() - Creates the exception thrown when type conversion fails. Receives the requested type name, property name, and the original TypeCastException as previous exception for context.

Custom Exceptions

Implement the exception factory methods to customize error handling:

class ApiResponse
{
    use CastingGettersTrait;
    
    public function __construct(private array $data) {}
    
    protected function getCastableValue(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }
    
    protected function createRequiredException(string $type, string $name): \Throwable
    {
        return new ApiException("Missing required $type field: $name");
    }
    
    protected function createCastException(string $type, string $name, \Throwable $previous): \Throwable
    {
        return new ApiException("Invalid $type value for field '$name'", 0, $previous);
    }
}

This allows each implementing class to:

  • Use domain-specific exception types
  • Customize error messages for better context
  • Include additional debugging information
  • Chain exceptions to preserve the original error details

Usage Patterns

Request/Form Handling

class Request
{
    use CastingGettersTrait;
    
    public function __construct(private array $query, private array $post) {}
    
    protected function getCastableValue(string $key): mixed
    {
        return $this->post[$key] ?? $this->query[$key] ?? null;
    }
}

$request = new Request($_GET, $_POST);

// Required fields throw on missing
$userId = $request->requireInt('user_id');

// Optional fields with defaults
$page = $request->getInt('page', 1);
$perPage = $request->getInt('per_page', 20);
$sortDesc = $request->getBool('sort_desc', false);

Configuration Object

class Config
{
    use CastingGettersTrait;
    
    public function __construct(private array $config) {}
    
    protected function getCastableValue(string $key): mixed
    {
        // Support dot notation for nested config
        $keys = explode('.', $key);
        $value = $this->config;
        
        foreach ($keys as $key) {
            if (!isset($value[$key])) return null;
            $value = $value[$key];
        }
        
        return $value;
    }
}

$config = new Config([
    'database' => [
        'host' => 'localhost',
        'port' => '5432',
    ],
    'cache' => [
        'enabled' => 'yes',
        'ttl' => '3600',
    ],
]);

$dbHost = $config->requireString('database.host');
$dbPort = $config->getInt('database.port', 5432);
$cacheEnabled = $config->getBool('cache.enabled', false);
$cacheTtl = $config->getInt('cache.ttl', 300);

API Response Wrapper

class ApiResponse
{
    use CastingGettersTrait;
    
    public function __construct(private array $data) {}
    
    protected function getCastableValue(string $key): mixed
    {
        return $this->data[$key] ?? null;
    }
    
    public function isSuccess(): bool
    {
        return $this->requireBool('success');
    }
    
    public function getErrorMessage(): ?string
    {
        return $this->getString('error');
    }
}

$response = new ApiResponse(json_decode($json, true));

if ($response->isSuccess()) {
    $userId = $response->requireInt('user_id');
    $username = $response->requireString('username');
} else {
    $error = $response->getErrorMessage() ?? 'Unknown error';
}

Database Row Object

class UserRow
{
    use CastingGettersTrait;
    
    public function __construct(private array $row) {}
    
    protected function getCastableValue(string $key): mixed
    {
        return $this->row[$key] ?? null;
    }
}

// Many DB drivers return all values as strings
$row = $pdo->fetch(PDO::FETCH_ASSOC);
$user = new UserRow($row);

$id = $user->requireInt('id');              // "123" → 123
$balance = $user->getFloat('balance');      // "19.99" → 19.99
$isActive = $user->getBool('is_active');    // "1" → true
$email = $user->getString('email');         // string

Implementation Requirements

Classes using the trait must implement:

abstract protected function getCastableValue(string $key): mixed;

This method should:

  • Return the raw value for the given property name
  • Return null for missing properties
  • Return the actual stored value for properties that exist (even if that value is null)

The trait handles all type conversion and error handling.

Requirements

PHP 8.1+

License

MIT License - See LICENSE file for details.