jsoizo / php-result
A type-safe Result type for PHP 8.1+ with PHPStan support.
Requires
- php: ^8.1
Requires (Dev)
README
A type-safe Result type for PHP 8.1+ with PHPStan support.
Features
- Zero dependencies
- PHPStan level max support
- Rich composition functions (map, flatMap, mapError)
- Inspired by functional programming Result/Either types
Installation
composer require jsoizo/php-result
Basic Usage
use Jsoizo\Result\Result; // Create Success/Failure $success = Result::success(42); $failure = Result::failure('error message'); // Transform values $doubled = $success->map(fn($x) => $x * 2); // Success(84) // Chain operations $result = $success ->flatMap(fn($x) => $x > 0 ? Result::success($x * 2) : Result::failure('must be positive')); // Get value with default $value = $failure->getOrElse(0); // 0 // Catch exceptions $result = Result::catch(fn() => riskyOperation()); // Handle both cases with fold $message = $result->fold( onFailure: fn($error) => "Error: {$error->getMessage()}", onSuccess: fn($value) => "Got: {$value}" ); // Compose validations with flatMap $result = validateEmail($input['email']) ->flatMap(fn($email) => validatePassword($input['password']) ->flatMap(fn($password) => createUser($email, $password))); // Recover from failure with default value $recovered = $failure->recover(fn($e) => 'default'); // Success('default') // Chain fallback operations $result = fetchFromPrimaryDb() ->recoverWith(fn($e) => fetchFromSecondaryDb()) ->recoverWith(fn($e) => Result::success('cached fallback')); // Side effects for debugging/logging $result = validateInput($data) ->tap(fn($v) => logger()->info("Valid: $v")) ->tapError(fn($e) => logger()->error("Invalid: $e")) ->flatMap(fn($v) => processData($v)); // Get value as nullable $value = $result->getOrNull(); // T|null // Flatten nested Results $nested = Result::success(Result::success(42)); $flat = $nested->flatten(); // Success(42) // Monad comprehension with binding (avoids nested flatMap) $result = Result::binding(function () use ($orderId) { /** @var Order $order */ $order = yield Result::catch(fn() => $orderRepo->find($orderId)); /** @var list<Item> $items */ $items = yield Result::catch(fn() => $order->loadItems()); return $items; }); // Returns Result<list<Item>, Throwable> - short-circuits on first failure // Accumulate errors from multiple independent validations $result = Result::accumulate3( fn() => validateName($input['name']), fn() => validateAge($input['age']), fn() => validateEmail($input['email']), fn(string $name, int $age, string $email) => new User($name, $age, $email) ); // All Success → Success(User(...)) // Any Failure → Failure(['Name required', 'Invalid email']) (non-empty-list of errors)
API
Factory Methods
| Method | Description |
|---|---|
Result::success($value) |
Create a Success |
Result::failure($error) |
Create a Failure |
Result::catch(callable $fn) |
Wrap exception-throwing code |
Result::binding(callable $fn) |
Monad comprehension using generators |
Result::accumulate2($fn1, ..., $transform) |
Combine 2 Results, collecting all errors |
Result::accumulate3($fn1, ..., $transform) |
Combine 3 Results, collecting all errors |
Result::accumulate4($fn1, ..., $transform) |
Combine 4 Results, collecting all errors |
Result::accumulate5($fn1, ..., $transform) |
Combine 5 Results, collecting all errors |
Result::accumulate6($fn1, ..., $transform) |
Combine 6 Results, collecting all errors |
Result::accumulate7($fn1, ..., $transform) |
Combine 7 Results, collecting all errors |
Result::accumulate8($fn1, ..., $transform) |
Combine 8 Results, collecting all errors |
Result::accumulate9($fn1, ..., $transform) |
Combine 9 Results, collecting all errors |
Instance Methods
| Method | Description |
|---|---|
isSuccess() |
Returns true if Success |
isFailure() |
Returns true if Failure |
getOrElse($default) |
Get value or default |
get() |
Get value or throw |
getErrorOrElse($default) |
Get error or default |
getError() |
Get error or throw ResultException |
map($fn) |
Transform success value |
mapError($fn) |
Transform error value |
flatMap($fn) |
Chain Result-returning operations |
fold($onFailure, $onSuccess) |
Handle both cases and return a value |
recover($fn) |
Recover from error with a value |
recoverWith($fn) |
Recover from error with a Result |
tap($fn) |
Execute side effect on success value, return same Result |
tapError($fn) |
Execute side effect on error value, return same Result |
getOrNull() |
Get success value or null |
flatten() |
Flatten nested Result<Result<T, E>, E> into Result<T, E> |
PHPStan Integration
Sealed Class Support
Result is marked as a sealed class using @phpstan-sealed. This prevents creating custom subclasses of Result outside of Success and Failure.
// PHPStan will report an error for unauthorized subclasses: // "Type CustomResult is not allowed to be a subtype of Result" class CustomResult extends Result { ... }
Requirements:
- PHPStan 2.1.18 or later
- No additional packages needed
Type Narrowing with isSuccess/isFailure
The isSuccess() and isFailure() methods support PHPStan type narrowing:
/** @param Result<User, ValidationError> $result */ function handleResult(Result $result): void { if ($result->isSuccess()) { // PHPStan knows $result is Success<User, ValidationError> $user = $result->get(); } else { // PHPStan knows $result is Failure<User, ValidationError> $error = $result->getError(); } } // Early return pattern /** @param Result<int, string> $result */ function getValue(Result $result): int { if ($result->isFailure()) { return -1; } // PHPStan knows $result is Success<int, string> return $result->get(); }
Match Exhaustiveness Check
This library includes a custom PHPStan rule that ensures match expressions on Result types are exhaustive.
Setup:
The rule is automatically enabled when you use PHPStan with this library (via composer.json extra config). Alternatively, add to your phpstan.neon:
includes: - vendor/jsoizo/php-result/extension.neon
What it checks:
// Error: Match expression on Result type is not exhaustive. Missing: Failure. match (true) { $result instanceof Success => 'success', }; // OK: All cases covered match (true) { $result instanceof Success => 'success', $result instanceof Failure => 'failure', }; // OK: default covers remaining cases match (true) { $result instanceof Success => 'success', default => 'failure', };
Note: PHPStan's match.unhandled error
Even when all cases are covered, PHPStan may report Match expression does not handle remaining value: true. This is because PHPStan doesn't use sealed class information for match exhaustiveness.
To suppress this error, use the @phpstan-ignore comment:
/** @phpstan-ignore match.unhandled (Result is sealed: Success|Failure) */ return match (true) { $result instanceof Success => 'success', $result instanceof Failure => 'failure', };
The custom rule in this library ensures exhaustiveness, so it's safe to ignore match.unhandled for Result types.