PHPackages                             mhert/strict-returns - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. mhert/strict-returns

ActiveLibrary

mhert/strict-returns
====================

Rust-style Result and Option types for PHP

0288PHPCI passing

Since Jul 12Pushed 1mo agoCompare

[ Source](https://github.com/mhert/strict-returns)[ Packagist](https://packagist.org/packages/mhert/strict-returns)[ RSS](/packages/mhert-strict-returns/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependenciesVersions (1)Used By (0)

strict-returns
==============

[](#strict-returns)

[![PHP](https://camo.githubusercontent.com/f31ce2f9d37653df3c359e6bbc3d2b59cc69dbae659a0643b670de801b753513/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135253230382e352d3737376262343f6c6f676f3d706870266c6f676f436f6c6f723d7768697465)](https://camo.githubusercontent.com/f31ce2f9d37653df3c359e6bbc3d2b59cc69dbae659a0643b670de801b753513/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135253230382e352d3737376262343f6c6f676f3d706870266c6f676f436f6c6f723d7768697465)[![License](https://camo.githubusercontent.com/8bb50fd2278f18fc326bf71f6e88ca8f884f72f179d3e555e20ed30157190d0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e2e737667)](https://camo.githubusercontent.com/8bb50fd2278f18fc326bf71f6e88ca8f884f72f179d3e555e20ed30157190d0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e2e737667)

Rust-style `Result` and `Option` types for PHP. Make **failure** and **absence** part of your return types — so callers can't quietly ignore them, and your static analyzer can prove they didn't.

> "I call it my billion-dollar mistake. It was the invention of the null reference in 1965."
>
> — **Tony Hoare**, inventor of the null reference

```
use StrictReturns\Result\Err;
use StrictReturns\Result\Ok;
use StrictReturns\Result\Result;

/** @return Result */
function divide(int $dividend, int $divisor): Result
{
    return $divisor === 0
        ? Result::err(MathError::DivisionByZero)
        : Result::ok(intdiv($dividend, $divisor));
}

$result = divide(10, 2);

echo match ($result::class) {
    Ok::class  => $result->value(),       // 5 — narrowed to Ok here
    Err::class => $result->error()->name, // the failure, as a value
};
```

---

Why
---

[](#why)

PHP has two conventional ways to say "this operation might not produce a value," and both are easy to get wrong:

**Returning `null`** — the absence is invisible in the type and trivial to forget:

```
$user = $repo->findByEmail($email); // User|null — but the signature often just says "User"
echo $user->name;                   // 💥 TypeError, far from where the null came from
```

**Throwing for expected failures** — control flow you can't see in the signature and can silently skip:

```
$price = $orders->priceFor($email, $qty); // may throw UnknownUserException... or may not. Who knows?

```

`strict-returns` moves the failure/absence into the return **value**:

- A function that can fail returns `Result` — either an `Ok` carrying a value or an `Err` carrying an error.
- A function that might find nothing returns `Option` — either `Some` with a value or `None`.

To read the value out you go through `value()`/`error()`, and the failure is part of the return **type** — a `Result` is not an `int`, so it can't be mistaken for one. Match on the concrete variant — `$result::class` against `Ok::class` / `Err::class` — and a static analyzer that understands the library's types (this project uses [Mago](https://github.com/carthage-software/mago)) narrows each arm to the exact type. Reach for the wrong side anyway and you get a loud `UnwrapException` at runtime instead of a silently wrong result.

Errors become ordinary values you pass around, branch on, and propagate — not exceptions thrown across half your call stack.

---

Requirements
------------

[](#requirements)

- PHP **8.5+**
- carthage-software/mago **1.43+**

Quick start
-----------

[](#quick-start)

### `Result` — success or failure

[](#resultt-e--success-or-failure)

```
use StrictReturns\Result\Err;
use StrictReturns\Result\Ok;
use StrictReturns\Result\Result;

enum MathError
{
    case DivisionByZero;
}

/** @return Result */
function divide(int $dividend, int $divisor): Result
{
    if ($divisor === 0) {
        return Result::err(MathError::DivisionByZero);
    }

    return Result::ok(intdiv($dividend, $divisor));
}

$result = divide(10, 0);

echo match ($result::class) {
    Ok::class  => "result: {$result->value()}",
    Err::class => "failed: {$result->error()->name}", // failed: DivisionByZero
};
```

### `Option` — value or nothing

[](#optiont--value-or-nothing)

```
use StrictReturns\Option\None;
use StrictReturns\Option\Option;
use StrictReturns\Option\Some;

final readonly class User
{
    public function __construct(
        public string $email,
        public string $name
    ) {
    }
}

/** @return Option */
function findByEmail(array $usersByEmail, string $email): Option
{
    if (!array_key_exists($email, $usersByEmail)) {
        return Option::none();
    }

    return Option::some($usersByEmail[$email]);
}

$users = ['ada@example.com' => new User('ada@example.com', 'Ada')];

$found = findByEmail($users, 'ada@example.com');

echo match ($found::class) {
    Some::class => $found->value()->name,
    None::class => 'no such user',
};
```

---

How "strict" works
------------------

[](#how-strict-works)

Two mechanisms keep you honest — one at analysis time, one at runtime. (Snippets from here on elide the `use` imports shown in the quick start.)

**1. Static narrowing.** Each variant is its own concrete class — `Ok`/`Err`, `Some`/`None` — so matching on `$result::class` narrows the object to the exact variant in each arm, and every accessor you reach for gets a precise type. Mago checks the match is exhaustive over the sealed `Ok`/`Err` pair, so **no `default` is needed:**

```
$result = divide(10, 2); // Result

echo match ($result::class) {
    // $result is narrowed to Ok here → value() returns int
    Ok::class  => "= {$result->value()}",
    // …and to Err here → error() returns MathError
    Err::class => "! {$result->error()->name}",
};
```

**2. Runtime guard.** If you reach for the wrong variant anyway, the accessor throws instead of returning a bogus value — the equivalent of Rust's `.unwrap()` panicking:

```
Result::ok(5)->error();                      // throws UnwrapException: "Called error() on an Ok value."
Result::err(MathError::DivisionByZero)->value(); // throws UnwrapException: "Called value() on an Err value."
Option::none()->value();                     // throws UnwrapException: "Called value() on a None value."
```

Each type ships its own exception — `Result` accessors throw `StrictReturns\Result\UnwrapException`, `Option` accessors throw `StrictReturns\Option\UnwrapException` — so catch the one matching the type you're unwrapping.

Coming from Rust: `isOk`/`isSome` map to `is_ok`/`is_some`, and `value()`/`error()` are the checked unwraps.

---

Composition without combinators
-------------------------------

[](#composition-without-combinators)

The API is intentionally tiny — there is no `map`, `andThen`, or `unwrapOr`. You branch with ordinary PHP and let the analyzer track the types.

**Propagate the first failure** — a narrowed `Err` is a valid `Result`, so you can return it straight through:

```
/** @return Result */
function divideChain(int $a, int $b, int $c): Result
{
    $first = divide($a, $b);

    return match ($first::class) {
        Err::class => $first,                      // narrowed to Err → a valid Result, propagated as-is
        Ok::class  => divide($first->value(), $c), // narrowed to Ok  → value() is safe
    };
}
```

**Provide a fallback** for an absent value:

```
$name = match ($found::class) {
    Some::class => $found->value()->name,
    None::class => 'guest',
};
```

**Combine both types** — consume an `Option` inside a `Result`-returning method:

```
/** @return Result */
public function priceFor(string $email, int $quantity): Result
{
    if ($quantity users->findByEmail($email);

    return match ($user::class) {
        Some::class => Result::ok($quantity * self::UNIT_PRICE),
        None::class => Result::err(OrderError::UnknownUser),
    };
}
```

**Unwrap or throw at a boundary** — when the caller can't proceed without the value (a controller, say), a `throw` arm collapses the match to the success type:

```
/** @param Option $found */
function requireUser(Option $found): User
{
    return match ($found::class) {
        Some::class => $found->value(),
        None::class => throw new RuntimeException('user not found'),
    };
}
```

---

API reference
-------------

[](#api-reference)

### `StrictReturns\Result\Result`

[](#strictreturnsresultresultt-e)

Abstract, `readonly`. Concrete variants: `Ok` and `Err`.

MemberReturnsDescription`Result::ok(mixed $value = null)``Ok`Success carrying `$value`. The default `null` models a payload-free success (Rust's `Result`).`Result::err(mixed $error)``Err`Failure carrying `$error`.`isOk(): bool``bool``true` for `Ok`. Narrows `$this` to `Ok` / `Err`.`isErr(): bool``bool``true` for `Err`. Narrows `$this` to `Err` / `Ok`.`value(): T``T`The success value. Throws `UnwrapException` when called on an `Err`.`error(): E``E`The error value. Throws `UnwrapException` when called on an `Ok`.### `StrictReturns\Option\Option`

[](#strictreturnsoptionoptiont)

Abstract, `readonly`. Concrete variants: `Some` and `None`.

MemberReturnsDescription`Option::some(mixed $value)``Some`A present value.`Option::none()``None`Absence.`isSome(): bool``bool``true` for `Some`. Narrows `$this` to `Some` / `None`.`isNone(): bool``bool``true` for `None`. Narrows `$this` to `None` / `Some`.`value(): T``T`The contained value. Throws `UnwrapException` when called on a `None`.### Exceptions

[](#exceptions)

- `StrictReturns\Result\UnwrapException` — thrown by `Result::value()` on an `Err` and `Result::error()` on an `Ok`.
- `StrictReturns\Option\UnwrapException` — thrown by `Option::value()` on a `None`.

Both extend `\Exception`.

---

Design notes
------------

[](#design-notes)

- **Immutable.** The `Result` and `Option` types are `readonly`; a `Result`/`Option` never changes after construction. (The `UnwrapException` classes extend `Exception`, which cannot be `readonly`.)
- **Covariant generics.** The type parameters are `@template-covariant`, so `Result::ok(5)` (an `Ok`) satisfies `Result`, and a narrowed `Err` can be returned where the wider `Result` is expected. This is what makes error propagation type-check cleanly.
- **No shared instances.** Every factory call — `Result::ok()`, `Result::err()`, `Option::some()`, `Option::none()` — returns a fresh instance, so all variants behave the same regardless of how they were created. Branch on `::class`, not on identity.
- **Minimal on purpose.** No monadic combinator zoo. The surface is construct → inspect → access, and everything else is plain PHP you can read at a glance.

---

License
-------

[](#license)

MIT © Mathias Hertlein. See [LICENSE](LICENSE).

###  Health Score

24

—

LowBetter than 30% of packages

Maintenance59

Moderate activity, may be stable

Popularity17

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/a1883af1538b8779350a34771d9eecf1f62417d311e220c146626ed1d2c72705?d=identicon)[mhert](/maintainers/mhert)

---

Top Contributors

[![mhert](https://avatars.githubusercontent.com/u/488510?v=4)](https://github.com/mhert "mhert (2 commits)")

### Embed Badge

![Health badge](/badges/mhert-strict-returns/health.svg)

```
[![Health](https://phpackages.com/badges/mhert-strict-returns/health.svg)](https://phpackages.com/packages/mhert-strict-returns)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
