jsoizo / php-result
A type-safe Result type for PHP 8.1+ with PHPStan support.
Requires
- php: ^8.1
Requires (Dev)
This package is auto-updated.
Last update: 2026-07-26 09:32:41 UTC
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
Requirements
The library runtime supports PHP 8.1+. The development and test tooling in this repository requires PHP 8.2+ because the locked Pest/PHPUnit toolchain requires PHP 8.2.
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 // Get value with lazy fallback computed from the error $value = $failure->getOr(fn($error) => strlen($error)); // 13 // The callback is only invoked on Failure, so expensive defaults are never built on success. // Catch exceptions $result = Result::catch(fn() => riskyOperation()); // Catch only an expected exception class; anything else is rethrown $result = Result::catch( fn() => json_decode($json, flags: JSON_THROW_ON_ERROR), JsonException::class ); // Result<mixed, JsonException> - a TypeError would propagate instead of becoming a Failure // Convert a nullable value into a Result $result = Result::fromNullable($userRepo->find($id), fn() => 'user not found'); // null → Failure('user not found'), non-null → Success(User) // Only null counts as absence: falsy values like '', 0, false become Success. // 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))); // If each step can fail differently, the error type is the union of all possible errors. // Recover from failure with default value $recovered = $failure->recover(fn($e) => 'default'); // Success('default') // recover() always returns a successful Result, so the error type becomes never. // 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 // Get error as nullable if (($error = $result->getErrorOrNull()) !== null) { logger()->error("Failed: $error"); } // Flatten nested Results $nested = Result::success(Result::success(42)); $flat = $nested->flatten(); // Success(42) // Result<Result<int, string>, string> becomes Result<int, string>. // 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 // Every yielded value must be a Result; invalid yields throw ResultException. // Accumulate a list of Results into one, collecting all errors $result = Result::accumulate([ validateName($input['name']), validateAge($input['age']), validateEmail($input['email']), ]); // All Success → Success([name, age, email]) // Any Failure → Failure(['Name required', 'Invalid email']) (non-empty-list of errors) // Sequence a list of Results into one, stopping at the first error $result = Result::sequence([ loadConfig($path), connectDb($dsn), fetchUser($id), ]); // All Success → Success([config, connection, user]) // Any Failure → Failure('config not found') (first error, unwrapped) // Accumulate errors from multiple independent validations $result = Result::accumulate3( validateName($input['name']), validateAge($input['age']), 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)
Use accumulate($results) for a homogeneous list of same-typed Results; use accumulate2()–accumulate9() to combine differently-typed Results into one value via a transform function. Use sequence($results) when you want fail-fast semantics instead: validation → accumulate (report all errors), sequential composition → sequence (stop at the first failure, error type stays as-is).
API
Factory Methods
| Method | Description |
|---|---|
Result::success($value) |
Create a Success |
Result::failure($error) |
Create a Failure |
Result::catch(callable $fn, string $exceptionClass = Throwable::class) |
Wrap exception-throwing code, optionally capturing only a given exception class |
Result::fromNullable($value, callable $onNull) |
Convert a nullable value into a Result |
Result::binding(callable $fn) |
Monad comprehension using generators |
Result::accumulate($results) |
Convert a list of Results into one Result, collecting all errors |
Result::sequence($results) |
Convert a list of Results into one Result, stopping at the first error |
Result::accumulate2($r1, ..., $transform) |
Combine 2 Results, collecting all errors |
Result::accumulate3($r1, ..., $transform) |
Combine 3 Results, collecting all errors |
Result::accumulate4($r1, ..., $transform) |
Combine 4 Results, collecting all errors |
Result::accumulate5($r1, ..., $transform) |
Combine 5 Results, collecting all errors |
Result::accumulate6($r1, ..., $transform) |
Combine 6 Results, collecting all errors |
Result::accumulate7($r1, ..., $transform) |
Combine 7 Results, collecting all errors |
Result::accumulate8($r1, ..., $transform) |
Combine 8 Results, collecting all errors |
Result::accumulate9($r1, ..., $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 |
getOr($fn) |
Get value or compute fallback lazily from error |
get() |
Get value or throw |
getErrorOrElse($default) |
Get error or default |
getErrorOr($fn) |
Get error or compute fallback lazily from value |
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 |
getErrorOrNull() |
Get error value or null |
flatten() |
Flatten nested Result<Result<T, E1>, E2> into Result<T, E1|E2> |
Error Types in flatMap
flatMap() preserves both the original error type and the error type returned by the callback:
/** @var Result<string, ValidationError> $result */ $saved = $result->flatMap(fn(string $value) => save($value)); // If save() returns Result<User, DbError>, $saved is Result<User, ValidationError|DbError>.
Long chains can naturally produce wide error unions. When that becomes awkward, normalize errors with mapError() to a domain-specific error type at a boundary.
Recovering with Different Success Types
recover() and recoverWith() can return a different success type than the original Result:
/** @var Result<int, string> $result */ $recovered = $result->recover(fn(string $error) => false); // Result<int|bool, never>
After recover(), the Result can no longer be a Failure. recoverWith() keeps the callback's error type because the fallback operation may still fail.
Flattening Nested Results
flatten() preserves nested generic types. If the success value is another Result, the inner success type is used and the outer and inner error types are combined:
/** @var Result<Result<int, DbError>, ValidationError> $result */ $flat = $result->flatten(); // Result<int, ValidationError|DbError>
Calling flatten() on a non-nested Result keeps the original type.
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.
Limitations:
The custom rule tracks simple variables in instanceof match arms, such as $result instanceof Success. It intentionally does not check property fetches or method calls such as $this->result instanceof Success or $this->getResult() instanceof Success.