iak / result
A return type for operations that can fail — make every failure visible, typed, and impossible to ignore
Requires
- php: ^8.2
Requires (Dev)
- laravel/pint: ^1.14
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-07-08 08:44:28 UTC
README
A Result is a return type for operations that can fail. Instead of throwing an
exception for an outcome you fully expect — an expired card, an empty stock, a
closed kitchen — you return it. For example, check out the following code:
return $placeOrder->handle($cart, $address) ->chain(fn (Order $order) => $charge->handle($order, $card)) ->match( success: fn (Receipt $receipt) => response()->json($receipt), failure: fn (OrderError|PaymentError $error) => response()->json(['error' => $error->message()], 422), );
Two operations that can each fail, one place where failure is handled, and not a try/catch in sight. Every possible outcome is right there in the types — your IDE sees them, your static analyser sees them, and the next developer sees them.
Installation
composer require iak/result
That's it. Results require PHP 8.2+ and nothing else — no framework, no configuration, no service provider.
Your First Result
Let's build something real: placing an order. You've probably written this controller a hundred times:
try { $order = $placeOrder->handle($request->cart(), $request->address()); } catch (KitchenClosedException) { return back()->withErrors(['order' => 'The kitchen is closed right now.']); } catch (OutOfStockException) { return back()->withErrors(['order' => 'Some items are out of stock.']); } catch (DeliveryUnavailableException) { return back()->withErrors(['order' => "We don't deliver to this address yet."]); } return redirect()->route('orders.show', $order);
It works — until someone adds a fourth failure mode to PlaceOrder and this
controller quietly starts responding with 500s. Nothing in handle()'s signature
says what it throws, and nothing checks that you caught it all.
Let's rebuild it with a Result. First, give every failure a name. An enum is perfect for this, and it gives the error messages a home too:
enum OrderError { case KitchenClosed; case OutOfStock; case DeliveryUnavailable; public function message(): string { return match ($this) { self::KitchenClosed => 'The kitchen is closed right now.', self::OutOfStock => 'Some items are out of stock.', self::DeliveryUnavailable => "We don't deliver to this address yet.", }; } }
Next, instead of throwing, return the outcome — either way:
use Iak\Result\Result; class PlaceOrder { /** @return Result<Order, OrderError> */ public function handle(Cart $cart, Address $address): Result { if (! $this->kitchen->isOpen()) { return Result::failure(OrderError::KitchenClosed); } if (! $cart->allItemsAvailable()) { return Result::failure(OrderError::OutOfStock); } if (! $this->zones->covers($address)) { return Result::failure(OrderError::DeliveryUnavailable); } return Result::success(Order::create($cart, $address)); } }
Notice the docblock: Result<Order, OrderError>. That one line now documents
every way this operation can end. No source-diving, no tribal knowledge.
Finally, the controller shrinks to a single expression:
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder) { return $placeOrder->handle($request->cart(), $request->address())->match( success: fn (Order $order) => redirect()->route('orders.show', $order), failure: fn (OrderError $error) => back()->withErrors(['order' => $error->message()]), ); }
Note
Where did the try/catch go? There's nothing to catch — failure is just a
return value now. And unlike a catch block, match() can't be forgotten: it's
the only way to get at the order, and it requires both arms.
And when someone adds that fourth failure mode? They add an enum case, the
match inside message() immediately demands a message for it, and every
consumer of the error gets flagged by static analysis. The failure mode is born
handled.
That's the whole pattern. Everything else in this package is convenience on top of it.
Available Methods
all · chain · error · expect · expectError · failure · isFailure · isSuccess · map · mapError · match · orElse · success · tap · tapError · value · valueOr · valueOrElse
Method Listing
success()
The static success method wraps a value in a successful result:
return Result::success($order);
You may call it without arguments when the operation has nothing meaningful to return — "it worked" is the whole message:
return Result::success();
failure()
The static failure method wraps an error in a failed result. The error may be
anything: an enum, a value object carrying context, a string, or an exception if
you already have one:
return Result::failure(OrderError::OutOfStock); return Result::failure(new AddressOutsideZone($address, $nearestZone)); return Result::failure($caughtException);
all()
The static all method combines a collection of results into a single result.
If every result succeeded, you get one success holding all the values with their
keys preserved. If any failed, you get the first failure back, untouched:
$result = Result::all([ 'order' => $placeOrder->handle($cart, $address), 'invoice' => $createInvoice->handle($cart), ]); // success: ['order' => Order, 'invoice' => Invoice] // failure: whichever failed first
You may pass any iterable. Iteration stops at the first failure, so a lazy generator won't do more work than necessary.
isSuccess()
The isSuccess method determines whether the operation succeeded:
if ($result->isSuccess()) { $order = $result->value(); // safe — and your static analyser agrees }
isFailure()
The isFailure method is the mirror of isSuccess. It shines in
guard clauses, keeping the happy path flat:
if ($result->isFailure()) { return back()->withErrors(['order' => $result->error()->message()]); } $order = $result->value();
Note
Prefer these methods over instanceof checks. Static analysers can't carry
the value and error types through a bare instanceof, but isSuccess() and
isFailure() keep them intact.
value()
The value method returns the success value:
$order = $result->value();
If the result is a failure, value throws a ResultException. An unguarded
call is therefore an assertion — "this can't fail here, and if I'm wrong I want
to hear about it." When you're not asserting, guard with
isFailure first or reach for valueOr.
error()
The error method returns the error value, throwing a ResultException if the
result is actually a success. You'll use it after a guard, and all over your
tests:
expect($result->error())->toBe(OrderError::OutOfStock);
expect()
The expect method works like value, but the exception carries your
message — so when the impossible happens, whoever's on call knows what you were
assuming:
$order = $result->expect('cart was validated in the previous step');
expectError()
The expectError method is expect for the error side:
$error = $result->expectError('the gateway was stubbed to fail in this test');
valueOr()
The valueOr method returns the success value, or your fallback if the
operation failed — for when you don't care why:
$eta = $estimateDelivery->handle($address)->valueOr(45);
valueOrElse()
The valueOrElse method computes the fallback from the error, and only when
it's actually needed:
$eta = $estimateDelivery->handle($address) ->valueOrElse(fn (EstimateError $error) => $error->conservativeGuess());
map()
The map method transforms the success value without unpacking the result. A
failure passes through untouched:
$result->map(fn (Order $order) => $order->total); // Result<Order, OrderError> becomes Result<Money, OrderError>
mapError()
The mapError method transforms the error instead. It's the tool for
translating low-level errors into your domain's language at a boundary:
$gateway->charge($card) ->mapError(fn (GatewayError $error) => PaymentError::fromGateway($error));
tap()
The tap method runs a side effect on the success value — logging, metrics,
notifications — and hands the result back unchanged:
return $placeOrder->handle($cart, $address) ->tap(fn (Order $order) => Log::info('order placed', ['id' => $order->id]));
tapError()
The tapError method does the same for the failure side. Together they let you
observe a pipeline without interrupting it:
return $placeOrder->handle($cart, $address) ->tap(fn (Order $order) => Log::info('order placed', ['id' => $order->id])) ->tapError(fn (OrderError $error) => Metrics::increment('orders.rejected'));
chain()
The chain method pipes the success value into the next operation that can
itself fail. The first failure short-circuits everything after it, so a whole
workflow needs exactly one failure handler:
return $placeOrder->handle($cart, $address) // Result<Order, OrderError> ->chain(fn (Order $order) => $charge->handle($order, $card)) // Result<Receipt, OrderError|PaymentError> ->match( success: fn (Receipt $receipt) => response()->json($receipt), failure: fn (OrderError|PaymentError $error) => response()->json(['error' => $error->message()], 422), );
Notice how the error types stack up: add a step, and every handler downstream is made aware of what it might have to deal with.
Note
Why isn't this called then()? Promise libraries — Guzzle, and therefore
Laravel's Http::async(), and ReactPHP — treat any object with a public
then() method as a promise and try to resolve it. A Result named that way
would break inside promise pipelines, so it's chain().
orElse()
The orElse method is recovery: on failure, try something else that can itself
succeed or fail. A success passes straight through:
$receipt = $chargeCard->handle($order, $card) ->orElse(fn (PaymentError $error) => $chargeWallet->handle($order));
match()
The match method handles both outcomes in one expression. Both arms are
required — the failure path can't be forgotten:
return $result->match( success: fn (Order $order) => redirect()->route('orders.show', $order), failure: fn (OrderError $error) => back()->withErrors(['order' => $error->message()]), );
Handling Different Error Types
Once you start chaining, the error side becomes a union — OrderError|PaymentError
— and you may want to treat them differently. PHP's own match has you covered:
$result->match( success: fn (Receipt $receipt) => response()->json($receipt), failure: fn (OrderError|PaymentError $error) => match (true) { $error instanceof OrderError => back()->withErrors(['order' => $error->message()]), $error instanceof PaymentError => $this->redirectToPaymentRetry($error), }, );
Static analysers check that inner match against the union — add a third error
type to the chain and this exact spot gets flagged until you handle it.
If every error is handled the same way, skip the dispatch entirely and give your errors a shared interface:
interface DomainError { public function message(): string; } // enum OrderError implements DomainError { ... } // enum PaymentError implements DomainError { ... } failure: fn (DomainError $error) => back()->withErrors(['order' => $error->message()]),
And when a union grows past comfort, normalize early: use
mapError at each boundary so downstream code only ever sees one
error type.
Good to Know
- Results are immutable. Transformations never modify a result in place — they hand you a new one (or the same one, untouched).
- Results compare structurally.
Result::success(1) == Result::success(1)istrue. - Results serialize. As long as the contained value serializes, a result survives caches and queues.
- Results are sealed.
SuccessandFailureare final, soisFailure() === falsealways means success. ResultExceptionis the only exception this package throws — fromvalue,error,expectandexpectErroron the wrong variant. It carries the offending value on->value, and when the error is itself aThrowable, you'll find it chained on->getPrevious()with its stack trace intact.
Development
composer test # Pest composer analyse # PHPStan, including the type-inference fixtures in types/ composer format # Pint
License
MIT. See LICENSE.md.