Search by

brimmar / phpresult

brimmar

Rust-like Option and Result types for PHP

Package info

github.com/brimmar/phpresult

pkg:composer/brimmar/phpresult

Statistics

Installs: 775

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v3.0.3 2026-10-14 20:55 UTC

This package is auto-updated.

Last update: 2026-10-14 20:55:44 UTC


README

Tests Static Analysis License Latest Stable Version Total Downloads

brimmar/phpresult ships two Rust-like types for PHP 8.5.

Option<T> is a value that might be missing. Some holds the value. None holds nothing. Missing means absence.

Result<T, E> is success or failure. Ok holds T. Err holds E. There is no Either type.

Both namespaces live in this package. Brimmar\PhpOption is Option. Brimmar\PhpResult is Result. Install it once. brimmar/phpoption is archived, and this package conflicts with it.

Methods that return an Option or a Result are #[\NoDiscard]. Ignoring the return value warns, because that drops a present value or a failure. Cast the call to void when you mean to ignore it.

composer require brimmar/phpresult

Table of contents

  1. Option
  2. Result
  3. Using them together
  4. Option methods
  5. Result methods
  6. Static analysis
  7. Contributing
  8. Security vulnerabilities
  9. License

Option

Option is the type you return when a lookup can miss.

<?php

use Brimmar\PhpOption\Interfaces\Option;
use Brimmar\PhpOption\None;
use Brimmar\PhpOption\Some;

class UserProfile
{
    private array $data = [];

    public function setField(string $field, mixed $value): void
    {
        $this->data[$field] = $value;
    }

    public function getField(string $field): Option
    {
        return isset($this->data[$field]) ? new Some($this->data[$field]) : new None();
    }

    public function getDisplayName(): string
    {
        return $this->getField('display_name')
            ->orElse(fn () => $this->getField('username'))
            ->unwrapOr('Anonymous');
    }

    public function getAge(): Option
    {
        return $this->getField('age')->andThen(function ($age) {
            return is_numeric($age) && $age > 0 && $age < 120
                ? new Some((int) $age)
                : new None();
        });
    }
}

$profile = new UserProfile();
$profile->setField('username', 'johndoe');
$profile->setField('age', '30');

echo $profile->getDisplayName(); // johndoe

echo $profile->getAge()
    ->map(fn ($age) => "User is $age years old")
    ->unwrapOr('Age not provided or invalid'); // User is 30 years old

echo $profile->getField('email')
    ->map(fn ($email) => "Contact: $email")
    ->unwrapOr('No email provided'); // No email provided

The same shape replaces optional chaining. Each step returns Option, so a missing user, address, or country stops the chain at None.

<?php

use Brimmar\PhpOption\Interfaces\Option;
use Brimmar\PhpOption\None;
use Brimmar\PhpOption\Some;

class Address
{
    public function __construct(
        public string $street,
        public string $city,
        public string $country,
    ) {}
}

class User
{
    public function __construct(
        public string $name,
        private ?Address $address = null,
    ) {}

    public function getAddress(): Option
    {
        return $this->address === null ? new None() : new Some($this->address);
    }
}

class UserRepository
{
    /** @var list<User> */
    private array $users = [];

    public function addUser(User $user): void
    {
        $this->users[] = $user;
    }

    public function findUserByName(string $name): Option
    {
        foreach ($this->users as $user) {
            if ($user->name === $name) {
                return new Some($user);
            }
        }

        return new None();
    }
}

$repo = new UserRepository();
$repo->addUser(new User('Alice', new Address('123 Main St', 'Springfield', 'USA')));
$repo->addUser(new User('Bob'));

function getUserCountry(UserRepository $repo, string $name): string
{
    return $repo->findUserByName($name)
        ->andThen(fn ($user) => $user->getAddress())
        ->map(fn ($address) => $address->country)
        ->unwrapOr('Country not found');
}

echo getUserCountry($repo, 'Alice'); // USA
echo getUserCountry($repo, 'Bob'); // Country not found
echo getUserCountry($repo, 'Charlie'); // Country not found

Result

Result is the type you return when the caller has to handle a failure.

<?php

use Brimmar\PhpResult\Err;
use Brimmar\PhpResult\Interfaces\Result;
use Brimmar\PhpResult\Ok;

class UserRegistration
{
    public function __construct(
        private Database $db,
        private EmailService $emailService,
    ) {}

    public function registerUser(string $username, string $email, string $password): Result
    {
        return $this->validateInput($username, $email, $password)
            ->andThen(fn () => $this->checkUserExists($username, $email))
            ->andThen(fn () => $this->hashPassword($password))
            ->andThen(fn ($hashedPassword) => $this->saveUser($username, $email, $hashedPassword))
            ->andThen(fn ($userId) => $this->sendWelcomeEmail($userId, $email));
    }

    private function validateInput(string $username, string $email, string $password): Result
    {
        if (strlen($username) < 3) {
            return new Err('Username must be at least 3 characters long');
        }
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return new Err('Invalid email address');
        }
        if (strlen($password) < 8) {
            return new Err('Password must be at least 8 characters long');
        }

        return new Ok(null);
    }

    private function checkUserExists(string $username, string $email): Result
    {
        $exists = $this->db->query(
            'SELECT id FROM users WHERE username = ? OR email = ?',
            [$username, $email],
        )->fetchColumn();

        return $exists ? new Err('Username or email already exists') : new Ok(null);
    }

    private function hashPassword(string $password): Result
    {
        $hashedPassword = password_hash($password, PASSWORD_DEFAULT);

        return $hashedPassword ? new Ok($hashedPassword) : new Err('Failed to hash password');
    }

    private function saveUser(string $username, string $email, string $hashedPassword): Result
    {
        $userId = $this->db->insert(
            'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
            [$username, $email, $hashedPassword],
        );

        return $userId ? new Ok($userId) : new Err('Failed to save user to database');
    }

    private function sendWelcomeEmail(int $userId, string $email): Result
    {
        $sent = $this->emailService->send($email, 'Welcome to our service!', 'Thank you for registering...');

        return $sent ? new Ok($userId) : new Err('Failed to send welcome email');
    }
}

echo (new UserRegistration($db, $emailService))
    ->registerUser('johndoe', 'john@example.com', 'password123')
    ->match(
        Ok: fn ($value) => "User registered successfully with ID: $value",
        Err: fn ($error) => "Registration failed: $error",
    );

orElse runs only on Err. A cache miss is an Err, so the client fetches from the API. A rate-limit Err stops the chain before that lookup.

<?php

use Brimmar\PhpResult\Err;
use Brimmar\PhpResult\Interfaces\Result;
use Brimmar\PhpResult\Ok;

class WeatherApiClient
{
    public function __construct(
        private HttpClient $httpClient,
        private CacheInterface $cache,
        private RateLimiter $rateLimiter,
        private string $apiKey,
    ) {}

    public function getWeatherForecast(string $city): Result
    {
        return $this->checkRateLimit()->andThen(
            fn () => $this->getCachedForecast($city)
                ->orElse(fn () => $this->fetchForecastFromApi($city))
                ->andThen(fn ($forecast) => $this->cacheForecast($city, $forecast)),
        );
    }

    private function checkRateLimit(): Result
    {
        return $this->rateLimiter->isAllowed('weather_api')
            ? new Ok(null)
            : new Err('Rate limit exceeded. Please try again later.');
    }

    private function getCachedForecast(string $city): Result
    {
        $cachedForecast = $this->cache->get("weather_forecast:$city");

        return $cachedForecast ? new Ok($cachedForecast) : new Err('Cache miss');
    }

    private function fetchForecastFromApi(string $city): Result
    {
        try {
            $response = $this->httpClient->get('https://api.weather.com/forecast', [
                'query' => ['city' => $city, 'apikey' => $this->apiKey],
            ]);

            if ($response->getStatusCode() !== 200) {
                return new Err('API request failed with status code: '.$response->getStatusCode());
            }

            return new Ok(json_decode($response->getBody(), true));
        } catch (Exception $e) {
            return new Err('Failed to fetch forecast: '.$e->getMessage());
        }
    }

    private function cacheForecast(string $city, array $forecast): Result
    {
        $cached = $this->cache->set("weather_forecast:$city", $forecast, 3600);

        return $cached ? new Ok($forecast) : new Err('Failed to cache forecast');
    }
}

echo (new WeatherApiClient($httpClient, $cache, $rateLimiter, 'your-api-key'))
    ->getWeatherForecast('New York')
    ->match(
        Ok: fn ($value) => 'Weather forecast for New York: '.$value['summary'],
        Err: fn ($error) => "Failed to get weather forecast: $error",
    );

or and orElse also express fallbacks that are still failures when every source misses.

<?php

use Brimmar\PhpResult\Err;
use Brimmar\PhpResult\Interfaces\Result;
use Brimmar\PhpResult\Ok;

class ConfigManager
{
    private array $configs = [];

    public function getConfig(string $key): Result
    {
        return $this->getFromEnvironment($key)
            ->or($this->getFromFile($key))
            ->orElse(fn () => $this->getDefaultConfig($key));
    }

    private function getFromEnvironment(string $key): Result
    {
        $value = getenv($key);

        return $value !== false ? new Ok($value) : new Err('Not found in environment');
    }

    private function getFromFile(string $key): Result
    {
        return isset($this->configs[$key])
            ? new Ok($this->configs[$key])
            : new Err('Not found in config file');
    }

    private function getDefaultConfig(string $key): Result
    {
        $defaults = ['timeout' => 30, 'retries' => 3];

        return isset($defaults[$key])
            ? new Ok($defaults[$key])
            : new Err("No default value for $key");
    }

    public function setConfig(string $key, mixed $value): void
    {
        $this->configs[$key] = $value;
    }
}

$manager = new ConfigManager();
$manager->setConfig('database_url', 'mysql://localhost/mydb');

$validDatabase = $manager->getConfig('database_url')
    ->map(fn ($url) => parse_url($url))
    ->isOkAnd(fn ($parsed) => isset($parsed['scheme'], $parsed['host'], $parsed['path']));

echo $validDatabase
    ? 'Valid database configuration found'
    : 'Invalid or missing database configuration';

echo 'Timeout set to: '.$manager->getConfig('timeout')->expect('Timeout configuration is required');

Using them together

Use Option when the honest answer is "nothing there". Use Result when the caller must hear why it failed. Convert at that boundary. The conversions return Option and Result directly.

okOr and okOrElse turn absence into a failure. Some becomes Ok. None becomes Err.

<?php

use Brimmar\PhpOption\Interfaces\Option;
use Brimmar\PhpOption\None;
use Brimmar\PhpOption\Some;
use Brimmar\PhpResult\Err;
use Brimmar\PhpResult\Interfaces\Result;
use Brimmar\PhpResult\Ok;

class Settings
{
    private array $configs = [];

    public function get(string $key): Option
    {
        return isset($this->configs[$key]) ? new Some($this->configs[$key]) : new None();
    }

    public function set(string $key, mixed $value): void
    {
        $this->configs[$key] = $value;
    }

    public function required(string $key): Result
    {
        return $this->get($key)->okOr("Required configuration '$key' is missing");
    }

    public function databaseUrl(): Result
    {
        return $this->required('database_url')->andThen(function ($url) {
            $parsed = parse_url($url);

            return isset($parsed['scheme'], $parsed['host'], $parsed['path'])
                ? new Ok($url)
                : new Err('Invalid database URL format');
        });
    }
}

$settings = new Settings();
$settings->set('database_url', 'mysql://localhost/mydb');
$settings->set('debug', true);

echo $settings->get('debug')->unwrapOr(false) ? 'Debug mode is ON' : 'Debug mode is OFF';

echo $settings->databaseUrl()->match(
    Ok: fn ($url) => "Connected to: $url",
    Err: fn ($error) => "Error: $error",
);

echo $settings->required('api_key')->match(
    Ok: fn ($key) => "API Key: $key",
    Err: fn ($error) => "Error: $error",
); // Error: Required configuration 'api_key' is missing

okOrElse builds that error lazily, so a Some never runs the closure.

$option = new Some(42);
$option->okOrElse(fn () => 'No value')->unwrap(); // 42

$missing = new None();
$missing->okOrElse(fn () => 'No value')->unwrapErr(); // No value

ok keeps the success and drops the error. err keeps the error and drops the success.

(new Ok(42))->ok()->unwrap(); // 42
(new Err('missing'))->ok()->isNone(); // true

(new Ok(42))->err()->isNone(); // true
(new Err('missing'))->err()->unwrap(); // missing

transpose swaps the nesting.

An Option of a Result becomes a Result of an Option. Some(Ok(v)) becomes Ok(Some(v)). Some(Err(e)) becomes Err(e). None becomes Ok(None).

(new Some(new Ok(42)))->transpose()->unwrap()->unwrap(); // 42
(new Some(new Err('error')))->transpose()->unwrapErr(); // error
(new None())->transpose()->unwrap()->isNone(); // true

A Result of an Option becomes an Option of a Result. Ok(Some(v)) becomes Some(Ok(v)). Ok(None) becomes None. Err(e) becomes Some(Err(e)).

(new Ok(new Some(42)))->transpose()->unwrap()->unwrap(); // 42
(new Ok(new None()))->transpose()->isNone(); // true
(new Err('error'))->transpose()->unwrap()->unwrapErr(); // error

A lookup is Option. An update that requires the row is Result. okOr is the handoff.

<?php

use Brimmar\PhpOption\Interfaces\Option;
use Brimmar\PhpOption\None;
use Brimmar\PhpOption\Some;
use Brimmar\PhpResult\Err;
use Brimmar\PhpResult\Interfaces\Result;
use Brimmar\PhpResult\Ok;

class UserService
{
    /** @var array<int, array{name: string, email: string, activities?: list<int>}> */
    private array $users = [];

    public function addUser(int $id, array $user): void
    {
        $this->users[$id] = $user;
    }

    public function findUser(int $id): Option
    {
        return isset($this->users[$id]) ? new Some($this->users[$id]) : new None();
    }

    public function updateUser(int $id, array $data): Result
    {
        return $this->findUser($id)
            ->okOr('User not found')
            ->andThen(fn () => $this->validateUserData($data))
            ->map(fn ($validData) => array_merge($this->users[$id], $validData))
            ->inspect(function ($updatedUser) use ($id) {
                $this->users[$id] = $updatedUser;
            });
    }

    private function validateUserData(array $data): Result
    {
        $errors = array_filter([
            'name' => strlen($data['name'] ?? '') < 2 ? 'Name too short' : null,
            'email' => filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL) ? null : 'Invalid email',
        ]);

        return $errors === [] ? new Ok($data) : new Err($errors);
    }

    public function score(int $id): Option
    {
        return $this->findUser($id)->andThen(function ($user) {
            return isset($user['activities']) ? new Some(array_sum($user['activities'])) : new None();
        });
    }
}

$service = new UserService();
$service->addUser(1, ['name' => 'Alice', 'email' => 'alice@example.com', 'activities' => [2, 3]]);

echo $service->updateUser(1, ['name' => 'Alicia', 'email' => 'alicia@example.com'])->match(
    Ok: fn ($user) => $user['name'],
    Err: fn ($error) => 'Update failed',
); // Alicia

echo $service->findUser(99)
    ->okOr('User not found')
    ->match(
        Ok: fn ($user) => $user['name'],
        Err: fn ($error) => $error,
    ); // User not found

echo $service->score(1)->unwrapOr(0); // 5

Option methods

isSome(): bool

Returns true when the option is Some.

(new Some(42))->isSome(); // true
(new None())->isSome(); // false

isSomeAnd(callable $fn): bool

Returns true when the option is Some and the value matches the predicate.

(new Some(42))->isSomeAnd(fn ($value) => $value > 40); // true
(new Some(42))->isSomeAnd(fn ($value) => $value < 40); // false
(new None())->isSomeAnd(fn ($value) => $value > 0); // false

isNone(): bool

Returns true when the option is None.

(new Some(42))->isNone(); // false
(new None())->isNone(); // true

iter(): Iterator

Iterates the contained value. None yields nothing. An array is one item. Walk the elements with foreach ($option->unwrapOr([]) as $item).

foreach ((new Some(42))->iter() as $value) {
    echo $value; // 42
}

unwrap(): mixed

Returns the contained value. None throws RuntimeException.

(new Some(42))->unwrap(); // 42

expect(string $msg): mixed

Returns the contained value. None throws RuntimeException with $msg.

(new Some(42))->expect('Value should be present'); // 42

flatten(): Option

Collapses Option<Option<T>> to Option<T>.

(new Some(new Some(42)))->flatten()->unwrap(); // 42
(new Some(new None()))->flatten()->isNone(); // true

unwrapOr(mixed $default): mixed

Returns the contained value, or $default for None.

(new Some(42))->unwrapOr(0); // 42
(new None())->unwrapOr(0); // 0

unwrapOrElse(callable $default): mixed

Returns the contained value, or calls $default for None.

(new Some(42))->unwrapOrElse(fn () => 0); // 42
(new None())->unwrapOrElse(fn () => 0); // 0

map(callable $fn): Option

Applies $fn to a Some value. None stays None.

(new Some(42))->map(fn ($x) => $x * 2)->unwrap(); // 84
(new None())->map(fn ($x) => $x * 2)->isNone(); // true

mapOr(mixed $default, callable $fn): mixed

Applies $fn to a Some value, or returns $default for None.

(new Some(42))->mapOr(0, fn ($x) => $x * 2); // 84
(new None())->mapOr(0, fn ($x) => $x * 2); // 0

mapOrElse(callable $default, callable $fn): mixed

Applies $fn to a Some value, or calls $default for None.

(new Some(42))->mapOrElse(fn () => 0, fn ($x) => $x * 2); // 84
(new None())->mapOrElse(fn () => 0, fn ($x) => $x * 2); // 0

inspect(callable $fn): Option

Calls $fn with the contained value when it is Some, then returns the same option.

echo (new Some(42))->inspect(function ($x) { echo "Got: $x "; })->unwrap(); // Got: 42 42

okOr(mixed $error): Result

Some(v) becomes Ok(v). None becomes Err($error).

(new Some(42))->okOr('No value')->unwrap(); // 42
(new None())->okOr('No value')->unwrapErr(); // No value

okOrElse(callable $error): Result

Some(v) becomes Ok(v). None becomes Err($error()). The closure runs only for None.

(new Some(42))->okOrElse(fn () => 'No value')->unwrap(); // 42
(new None())->okOrElse(fn () => 'No value')->unwrapErr(); // No value

and(Option $opt): Option

Returns $opt when this option is Some. None stays None.

(new Some(42))->and(new Some(10))->unwrap(); // 10
(new None())->and(new Some(10))->isNone(); // true

andThen(callable $fn): Option

Calls $fn with the contained value when this option is Some. $fn must return an Option. None stays None.

(new Some(42))->andThen(fn ($x) => new Some($x * 2))->unwrap(); // 84
(new None())->andThen(fn ($x) => new Some($x * 2))->isNone(); // true

or(Option $opt): Option

Returns this option when it is Some, otherwise $opt.

(new Some(42))->or(new Some(10))->unwrap(); // 42
(new None())->or(new Some(10))->unwrap(); // 10

orElse(callable $fn): Option

Returns this option when it is Some. None calls $fn, which must return an Option.

(new Some(42))->orElse(fn () => new Some(10))->unwrap(); // 42
(new None())->orElse(fn () => new Some(10))->unwrap(); // 10

transpose(): Result

Turns Option<Result<U, E>> into Result<Option<U>, E>.

Some(Ok(v)) becomes Ok(Some(v)). Some(Err(e)) becomes Err(e). None becomes Ok(None).

(new Some(new Ok(42)))->transpose()->unwrap()->unwrap(); // 42
(new Some(new Err('error')))->transpose()->unwrapErr(); // error
(new None())->transpose()->unwrap()->isNone(); // true

xor(Option $opt): Option

Returns Some when exactly one of the two options is Some. Two Some values, or two None values, become None.

(new Some(42))->xor(new None())->unwrap(); // 42
(new Some(42))->xor(new Some(10))->isNone(); // true

zip(Option $other): Option

Pairs the two contained values. A None on either side makes the result None.

(new Some(42))->zip(new Some('hello'))->unwrap(); // [42, 'hello']
(new Some(42))->zip(new None())->isNone(); // true

zipWith(Option $other, callable $fn): Option

Pairs the two contained values through $fn. A None on either side makes the result None.

(new Some(42))->zipWith(new Some(10), fn ($a, $b) => $a + $b)->unwrap(); // 52
(new Some(42))->zipWith(new None(), fn ($a, $b) => $a + $b)->isNone(); // true

unzip(): array

Splits Some([a, b]) into [Some(a), Some(b)]. None becomes [None, None].

[$a, $b] = (new Some([42, 'hello']))->unzip();
$a->unwrap(); // 42
$b->unwrap(); // hello

[$a, $b] = (new None())->unzip();
$a->isNone(); // true
$b->isNone(); // true

match(callable $Some, callable $None): mixed

Runs $Some for a value and $None for absence.

(new Some(42))->match(
    Some: fn ($x) => "Value is $x",
    None: fn () => 'No value',
); // Value is 42

(new None())->match(
    Some: fn ($x) => "Value is $x",
    None: fn () => 'No value',
); // No value

filter(callable $predicate): Option

Keeps a Some whose value passes $predicate. A failing predicate, and None, both return None.

(new Some(42))->filter(fn ($x) => $x > 40)->unwrap(); // 42
(new Some(42))->filter(fn ($x) => $x < 40)->isNone(); // true
(new None())->filter(fn ($x) => $x > 0)->isNone(); // true

Result methods

isOk(): bool

Returns true when the result is Ok.

(new Ok(42))->isOk(); // true
(new Err('error'))->isOk(); // false

isOkAnd(callable $fn): bool

Returns true when the result is Ok and the value matches the predicate.

(new Ok(42))->isOkAnd(fn ($value) => $value > 40); // true
(new Ok(42))->isOkAnd(fn ($value) => $value < 40); // false
(new Err('error'))->isOkAnd(fn ($value) => true); // false

isErr(): bool

Returns true when the result is Err.

(new Ok(42))->isErr(); // false
(new Err('error'))->isErr(); // true

isErrAnd(callable $fn): bool

Returns true when the result is Err and the error matches the predicate.

(new Err('error'))->isErrAnd(fn ($error) => $error === 'error'); // true
(new Err('error'))->isErrAnd(fn ($error) => $error === 'other'); // false
(new Ok(42))->isErrAnd(fn ($error) => true); // false

ok(): Option

Ok(v) becomes Some(v). Err becomes None. The error is dropped.

(new Ok(42))->ok()->unwrap(); // 42
(new Err('error'))->ok()->isNone(); // true

err(): Option

Err(e) becomes Some(e). Ok becomes None. The success value is dropped.

(new Ok(42))->err()->isNone(); // true
(new Err('error'))->err()->unwrap(); // error

unwrap(): mixed

Returns the contained Ok value. Err throws RuntimeException.

(new Ok(42))->unwrap(); // 42

unwrapErr(): mixed

Returns the contained Err value. Ok throws RuntimeException.

(new Err('error'))->unwrapErr(); // error

expect(string $msg): mixed

Returns the contained Ok value. Err throws RuntimeException with $msg.

(new Ok(42))->expect('Failed to get value'); // 42

expectErr(string $msg): mixed

Returns the contained Err value. Ok throws RuntimeException with $msg.

(new Err('error'))->expectErr('Failed to get error'); // error

intoOk(): mixed

Returns the contained Ok value. Err throws RuntimeException.

(new Ok(42))->intoOk(); // 42

intoErr(): mixed

Returns the contained Err value. Ok throws RuntimeException.

(new Err('error'))->intoErr(); // error

flatten(): Result

Collapses Result<Result<T, E>, E> to Result<T, E>. Err is returned unchanged, even when the error is a Result.

(new Ok(new Ok(42)))->flatten()->unwrap(); // 42
(new Ok(new Err('inner error')))->flatten()->unwrapErr(); // inner error
(new Err('outer error'))->flatten()->unwrapErr(); // outer error

iter(): Iterator

Iterates the contained Ok value. Err yields nothing. An array is one item. Walk the elements with foreach ($result->unwrapOr([]) as $item).

foreach ((new Ok(42))->iter() as $value) {
    echo $value; // 42
}

unwrapOr(mixed $default): mixed

Returns the contained Ok value, or $default for Err.

(new Ok(42))->unwrapOr(0); // 42
(new Err('error'))->unwrapOr(0); // 0

unwrapOrElse(callable $fn): mixed

Returns the contained Ok value, or calls $fn with the error.

(new Ok(42))->unwrapOrElse(fn () => 0); // 42
(new Err('error'))->unwrapOrElse(fn ($error) => strlen($error)); // 5

map(callable $fn): Result

Applies $fn to an Ok value. Err stays Err.

(new Ok(42))->map(fn ($value) => $value * 2)->unwrap(); // 84
(new Err('error'))->map(fn ($value) => $value * 2)->unwrapErr(); // error

mapErr(callable $fn): Result

Applies $fn to an Err value. Ok stays Ok.

(new Err('error'))->mapErr(fn ($error) => strtoupper($error))->unwrapErr(); // ERROR
(new Ok(42))->mapErr(fn ($error) => strtoupper($error))->unwrap(); // 42

mapOr(mixed $default, callable $fn): mixed

Applies $fn to an Ok value, or returns $default for Err.

(new Ok(42))->mapOr(0, fn ($value) => $value * 2); // 84
(new Err('error'))->mapOr(0, fn ($value) => $value * 2); // 0

mapOrElse(callable $default, callable $fn): mixed

Applies $fn to an Ok value, or $default to an Err value.

(new Ok(42))->mapOrElse(fn ($error) => strlen($error), fn ($value) => $value * 2); // 84
(new Err('error'))->mapOrElse(fn ($error) => strlen($error), fn ($value) => $value * 2); // 5

inspect(callable $fn): Result

Calls $fn with the contained value when it is Ok, then returns the same result.

echo (new Ok(42))->inspect(function ($value) { echo "Got value: $value "; })->unwrap(); // Got value: 42 42

inspectErr(callable $fn): Result

Calls $fn with the contained error when it is Err, then returns the same result.

echo (new Err('error'))->inspectErr(function ($error) { echo "Got error: $error "; })->unwrapErr(); // Got error: error error

and(Result $res): Result

Returns $res when this result is Ok. An Err is returned unchanged.

(new Ok(42))->and(new Ok(10))->unwrap(); // 10
(new Err('error'))->and(new Ok(10))->unwrapErr(); // error

andThen(callable $fn): Result

Calls $fn with the contained value when this result is Ok. $fn must return a Result. An Err is returned unchanged.

(new Ok(42))->andThen(fn ($value) => new Ok($value * 2))->unwrap(); // 84
(new Err('error'))->andThen(fn ($value) => new Ok($value * 2))->unwrapErr(); // error

or(Result $res): Result

Returns this result when it is Ok, otherwise $res.

(new Ok(42))->or(new Ok(10))->unwrap(); // 42
(new Err('error1'))->or(new Ok(10))->unwrap(); // 10

orElse(callable $fn): Result

Returns this result when it is Ok. An Err calls $fn with the error. $fn must return a Result.

(new Ok(42))->orElse(fn ($error) => new Ok($error.' handled'))->unwrap(); // 42
(new Err('error'))->orElse(fn ($error) => new Ok($error.' handled'))->unwrap(); // error handled

transpose(): Option

Turns Result<Option<U>, E> into Option<Result<U, E>>.

Ok(Some(v)) becomes Some(Ok(v)). Ok(None) becomes None. Err(e) becomes Some(Err(e)).

(new Ok(new Some(42)))->transpose()->unwrap()->unwrap(); // 42
(new Ok(new None()))->transpose()->isNone(); // true
(new Err('error'))->transpose()->unwrap()->unwrapErr(); // error

match(callable $Ok, callable $Err): mixed

Runs $Ok for a success and $Err for a failure.

(new Ok(42))->match(
    Ok: fn ($value) => "Success: $value",
    Err: fn ($error) => "Error: $error",
); // Success: 42

(new Err('error'))->match(
    Ok: fn ($value) => "Success: $value",
    Err: fn ($error) => "Error: $error",
); // Error: error

Static analysis

Two PHPStan extensions are optional, and each one stands on its own. brimmar/phpstan-rustlike-option-extension checks Option. brimmar/phpstan-rustlike-result-extension checks Result. The checks below ship in the 2.0.0 releases of those extensions and need PHPStan 2.3. The 1.0 releases do not include them. Add the neon file for the one you install.

composer require --dev brimmar/phpstan-rustlike-option-extension brimmar/phpstan-rustlike-result-extension
includes:
    - vendor/brimmar/phpstan-rustlike-option-extension/extension.neon
    - vendor/brimmar/phpstan-rustlike-result-extension/extension.neon

Option extension

unwrap() and expect() throw when the value is None. PHPStan accepts the call when the receiver is Some, including (new Some(1))->unwrap() and the lines after if ($option->isNone()) return. A bare Option, a check on a different variable, and the isNone() branch are option.unsafeMethod.

function label(Option $option): string
{
    return $option->unwrap(); // option.unsafeMethod
}
function label(Option $option): string
{
    if ($option->isNone()) {
        return 'missing';
    }

    return $option->unwrap();
}

isSomeAnd() narrows the true branch only.

match() accepts Some and None in either order. A positional call or a different name is option.matchNamedArgs. One argument is option.matchArgs.

$option->match(
    None: fn () => 'missing',
    Some: fn ($value) => $value,
);

An unused Option is option.unused. That includes new Some(1); and a function of yours. The pipe operator does not hide it.

/** @return Option<int> */
function find(int $id): Option
{
    return new Some($id);
}

find(1); // option.unused

$_ = find(1);

Methods that return an Option already use #[\NoDiscard]. Ignoring one is method.resultDiscarded. (void) acknowledges that drop. On a function without the attribute, the same cast is function.inVoidCast, so assign the call to $_ instead.

(void) $option->map(fn ($value) => $value);

flatten(), transpose(), and unzip() are reported when the contained type is definitely wrong. option.flatten wants Option<Option<T>>. option.transpose wants Option<Result<T, E>>. option.unzip wants an array. mixed and a template type are left alone.

/** @param Option<int> $option */
function collapsed(Option $option): mixed
{
    return $option->flatten(); // option.flatten
}

Result extension

unwrap(), expect(), and intoOk() throw on Err. unwrapErr(), expectErr(), and intoErr() throw on Ok. The call is result.unsafeMethodCall while that side is still possible. An Ok value passes the success calls, and an Err value passes the failure calls. isOk() and isErr() narrow a Result, so the matching call is accepted in that branch and after an early return. Result<T, never> accepts the success calls. Result<never, E> accepts the failure calls. The contained type stays intact, so unwrap() on Result<int, string> is still int after isErr() returns early.

/** @param Result<int, string> $result */
function id(Result $result): int
{
    return $result->unwrap(); // result.unsafeMethodCall
}
/** @param Result<int, string> $result */
function id(Result $result): int
{
    if ($result->isErr()) {
        return 0;
    }

    return $result->unwrap();
}

isOkAnd() and isErrAnd() narrow the true branch only. unwrapErr() in the else of isOkAnd() is still result.unsafeMethodCall.

map() and mapErr() transform a value. A throw inside the callback leaves the Result, and the extension reports it as result.mapException. Return an Err from andThen when the failure belongs in the Result.

$result->map(fn (int $id) => $id > 0 ? $id : throw new RuntimeException('bad id')); // result.mapException

$result->andThen(fn (int $id) => $id > 0 ? new Ok($id) : new Err('bad id'));

match() accepts Ok and Err in either order. A positional call or a different name is result.matchNamedArgs.

$result->match(
    Err: fn ($error) => $error,
    Ok: fn ($value) => $value,
);

flatten() and transpose() are reported when the contained type is definitely wrong. result.flatten wants Result<Result<T, E>, E>. result.transpose wants Result<Option<T>, E>.

/** @param Result<int, string> $result */
function collapsed(Result $result): mixed
{
    return $result->flatten(); // result.flatten
}

A dropped Result is result.unused. new Ok(1); counts. Result<int, never> does not, because that error cannot exist. Result<Option<int>, never> still does, because the option inside may be None. (void) and $_ work the same way as they do for Option.

Contributing

Please see CONTRIBUTING.md.

Security vulnerabilities

Please see SECURITY.md.

License

This project is licensed under the MIT License. See LICENSE.md.