mhert/strict-returns
Composer 安装命令:
composer require mhert/strict-returns
包简介
Rust-style Result and Option types for PHP
关键字:
README 文档
README
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<int, MathError> */ 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
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<T, E>— either anOkcarrying a value or anErrcarrying an error. - A function that might find nothing returns
Option<T>— eitherSomewith a value orNone.
To read the value out you go through value()/error(), and the failure is part of the return type — a Result<int, MathError> 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) 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
- PHP 8.5+
- carthage-software/mago 1.43+
Quick start
Result<T, E> — success or failure
use StrictReturns\Result\Err; use StrictReturns\Result\Ok; use StrictReturns\Result\Result; enum MathError { case DivisionByZero; } /** @return Result<int, MathError> */ 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<T> — 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<User> */ 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
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<int, MathError> echo match ($result::class) { // $result is narrowed to Ok<int, MathError> here → value() returns int Ok::class => "= {$result->value()}", // …and to Err<int, MathError> 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
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<T, E> is a valid Result<T, E>, so you can return it straight through:
/** @return Result<int, MathError> */ 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<int, OrderError> */ public function priceFor(string $email, int $quantity): Result { if ($quantity <= 0) { return Result::err(OrderError::InvalidQuantity); } $user = $this->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<User> $found */ function requireUser(Option $found): User { return match ($found::class) { Some::class => $found->value(), None::class => throw new RuntimeException('user not found'), }; }
API reference
StrictReturns\Result\Result<T, E>
Abstract, readonly. Concrete variants: Ok<T, E> and Err<T, E>.
| Member | Returns | Description |
|---|---|---|
Result::ok(mixed $value = null) |
Ok<T, never> |
Success carrying $value. The default null models a payload-free success (Rust's Result<(), E>). |
Result::err(mixed $error) |
Err<never, E> |
Failure carrying $error. |
isOk(): bool |
bool |
true for Ok. Narrows $this to Ok<T, E> / Err<T, E>. |
isErr(): bool |
bool |
true for Err. Narrows $this to Err<T, E> / Ok<T, E>. |
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<T>
Abstract, readonly. Concrete variants: Some<T> and None<T>.
| Member | Returns | Description |
|---|---|---|
Option::some(mixed $value) |
Some<T> |
A present value. |
Option::none() |
None<never> |
Absence. |
isSome(): bool |
bool |
true for Some. Narrows $this to Some<T> / None<T>. |
isNone(): bool |
bool |
true for None. Narrows $this to None<T> / Some<T>. |
value(): T |
T |
The contained value. Throws UnwrapException when called on a None. |
Exceptions
StrictReturns\Result\UnwrapException— thrown byResult::value()on anErrandResult::error()on anOk.StrictReturns\Option\UnwrapException— thrown byOption::value()on aNone.
Both extend \Exception.
Design notes
- Immutable. The
ResultandOptiontypes arereadonly; aResult/Optionnever changes after construction. (TheUnwrapExceptionclasses extendException, which cannot bereadonly.) - Covariant generics. The type parameters are
@template-covariant, soResult::ok(5)(anOk<int, never>) satisfiesResult<int, MathError>, and a narrowedErrcan be returned where the widerResultis 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
MIT © Mathias Hertlein. See LICENSE.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-12