iak/result

A return type for operations that can fail — make every failure visible, typed, and impossible to ignore

Maintainers

Package info

github.com/iaK/result

pkg:composer/iak/result

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-08 05:47 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) is true.
  • Results serialize. As long as the contained value serializes, a result survives caches and queues.
  • Results are sealed. Success and Failure are final, so isFailure() === false always means success.
  • ResultException is the only exception this package throws — from value, error, expect and expectError on the wrong variant. It carries the offending value on ->value, and when the error is itself a Throwable, 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.