Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions proposed/error-result-meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Error & Result Handling Meta Document

## 1. Summary

This PSR proposes standard interfaces for representing operation results and errors in a type-safe, composable manner. It defines:

- A `ResultInterface` representing the outcome of an operation (success or failure and the actual result value)
- An `ErrorInterface` representing detailed error information
- Standard patterns for chaining, transforming, and inspecting results

This actually enables libraries to return predictabe, structured outcomes without exceptions while maintaining interoperability.

Note: Error messages follow PSR-3 placeholder semantics. This enables deferred formatting, localization, and structured validation output without imposing a formatter or translator.

Note #2: `ResultFactoryInterface` is OPTIONAL. Libraries and apps MAY use direct construction or static named constructors (like Result::success()), if they control the implementation. The factory interface exists only to enable interoperable creation when implementations are abstracted.

Note #3: This PSR implements the well-established Either monad (from functional programming), commonly used in Haskell, Scala, Rust, and Kotlin. The interface provides map(fmap), mapError(leftMap), then(flatMap/bind), and fold(either)—all standard Either operations.

## 2. Why Bother?

### Current Problems

1. Libraries use mixed approaches (exceptions, error codes, null returns)
2. PHP's type system cant distinguish between valid returns and errors
3. Simple `false` or `null` returns don't carry error details
4. Each framework implements its own result/error objects
5. Not all failures are exceptional; some are expected business cases

### Benefits

- Chain operations without try-catch nesting
- Preserve error context across bondaries
- Libraries can share error semantics
- Avoids exception overhead for expected failures
- Distinguishes between technical errors and business rule violations
- _PHPStan/PHPCS/Psalm can verify error handling_

## 3. Scope

### 3.1 Goals

- Define standard interfaces for operation results
- Provide base implementation for common use cases
- Enable interoperability between error-aware libraries
- Support both synchronous and asynchronous patterns
- Integrate with existing PSRs (PSR-3 logging, PSR-14 events)
- Be compatible with PHP 7.4+ type systems

### 3.2 Non-Goals

- **Not** replacing exceptions for truly exceptional conditions
- **Not** prescribing logging or monitoring implementation
- **Not** definng transport/serialization formats
- **Not** handling global error/exception handlers
- **Not** replacing HTTP status codes in PSR-7/15/18

## 4. Approaches

### 4.1 Chosen Approach: Tagged Union with Monadic Methods

**Why this approach:**

- PHP's type system supports it via `isSuccess()`/`isFailure()` discrimination
- Provides both imperative `if` and functional `map()` access patterns
- Familiar to developers from modern languges (Rust, Kotlin, Swift...)
- Maintains PHP's pragmatic balance between OOP and functional patterns
- Can be extended for async/await patterns when Fibers mature

## 5. Backward Compatibility

For gradual adoption, libraries MAY:

1. Add new methods returning `ResultInterface` alongside old methods
2. Provide adapters from exceptions to results

## 6. Error Classification

Implementations MAY extend error types:

- `ValidationError` (business rule violations)
- `NotFoundError` (missing resources)
- `ConflictError` (state conflicts)
- `AuthenticationError` (auth failures)
- `AuthorizationError` (permission issues)
- `InfrastructureError` (system failures)

## 7. Typing and Generics

Since PHP does not currently support generics at the language level:

- This PSR uses phpdoc-based generics (@template) to express the relationship between success values and error values, following established practice in the PHP ecosystem.
- Implementations are expected to enforce the invariant that a Result contains either a success value or an error, but never both.
- Consumers SHOULD rely on `isSuccess()` / `isFailure()` (or equivalent terminal operations such as `fold()`) before accessing the contained value.
- This PSR does not require nor encourage implementors to create separate result classes for each possible value or error type.

## 8. Namespaces

- `ErrorInterface` is defined in the `Psr\Error` namespace to allow error objects to be reused independently of `ResultInterface`.
- Errors in this PSR are plain value objects that may be created, transformed, transported, logged, or rendered without necessarily being wrapped in a `Result`.
- `ResultInterface` composes an error but does not own the error abstraction. Keeping these concerns in separate namespaces avoids coupling error representation to a single control-flow mechanism and preserves flexibility for future use-cases.

## 9. People

- **PHP-FIG team**
- **Proposer:** Yousha Aleayoub - [blog](https://yousha.blog.ir)
- **Discussion Group:** https://groups.google.com/g/php-fig/c/OpEuvGERM5A
262 changes: 262 additions & 0 deletions proposed/error-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
# DRAFT: Error and Result Handling

## 1. Overview

This document describes standard interfaces for representing operation results in PHP applications.

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://tools.ietf.org/html/rfc2119).

## 2. Definitions

- **Result**: The outcome of an operation that may succeed or fail.
- **Success Result**: A result containing a succesful value.
- **Failure Result**: A result containing error information.
- **Error**: Structured information about why an operation failed.

## 3. Interfaces

### 3.1 `ErrorInterface`

```php
<?php

namespace Psr\Error;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The namespace for ErrorInterface and ResultInterface should be the same, in my opinion:

Suggested change
namespace Psr\Error;
namespace Psr\Result;

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be and I'm okay by your change, but here is my reason:
For clarity and separation of concerns, to load only Error but no Result, & following PSR conventions...
So future PSRs can depend on Error only, without inheriting Result semantics.

I have added a Namespaces section in meta document: https://github.com/php-fig/fig-standards/pull/1344/files#diff-99b9802e69456918264b6be5f78aa91cffedf996d1fe910b0e817c4d30d9be72R92

So ErrorInterface defined in Psr\Error to allow its reuse independently of ResultInterface... including validation, err transport, and normalization use cases.
And ResultInterface composes an error but does NOT own the error abstraction.

?


/**
* Represents structured error information.
*/
interface ErrorInterface extends \Throwable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems problematic to me. One of the stated goals of this PSR is to avoid the overhead of Throwable/Exception, so it doesn't make sense for the error to extend Throwable.

To make it easier to "throw an error" then I would suggest adding a method getException(): Throwable (or getThrowable) to allow for:

throw $error->getException();

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would reject that, because:

1- My goal was "Avoids exception overhead for expected failures", not Throwable.
2- Many PSR-compliant tools (loggers, HTTP clients...) expect Throwable. Returning a non-Throwable error breaks interoperability.

Note that Throwable is a umbrella INTERFACE for all throwables, while exception is a application-level CLASS for errors(which is overused)

And I think toThrowable() duplicates data: every err must also hold or generate a separate Throwable. That's more overhead, not less.

?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Throwable is an interface that can't be implemented directly by PHP code. so you have to extend an existing exception class to implement it
  2. I don't get your point, nothing is preventing to use a Throwable as the error type. it's just weird to have to (and it has an additional cost)

And I think toThrowable() duplicates data: every err must also hold or generate a separate Throwable. That's more overhead, not less.

only if toThrowable() is called, which may not be needed in a lot of cases

and there's still the issue that getCode() in your interface is incompatible with getCode() from Throwable, that's not valid : https://3v4l.org/6bpap#vnull

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, let's fix it

We have 2 ways:

1:

interface ErrorInterface extends \Throwable
{
    public function getCode(): int | string;
}

2:

interface ErrorInterface extends \Throwable
{
   public function getErrorCode(): string;
}

Which one is more acceptable?

{
/**
* Returns a machine-readable error code.
*
* This code is a stable identifier intended for programmatic use
* (like UUIDs, HTTP status codes, enum-like values, localization, PDO/SQLSTATE), and is not related
* to Throwable::getCode().
*/
public function getCode(): string;

/**
* Returns a human-readable error message.
*
* The message MAY contain placeholders in the form `{placeholder}`.
* Context values MAY be provided to replace placeholders for display,
* logging, or translation purposes.
*
* Message formatting and translation are the responsibility of the consumer.
*/
public function getMessage(): string;

/**
* Returns structured context data for the error.
*
* @return array<string, mixed>
*/
public function getContext(): array;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than an array, which has poor type validation, maybe it would make more sense to have this be a value getter? As in:

public function getContext(string $key): mixed;

This would imply another modification:

public function withContext(string $key, mixed $value): self;

While it may be slightly less convenient, this signature avoids array "blobs" that are hard to validate and provides a more structured way to access context. For instance, an error could define context keys as constants:

$minLength = $error->getContext($error::MIN_LENGTH);
$maxLength = $error->getContext($error::MAX_LENGTH);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh the validation here is not really better, static analysis tools would already warn if you didn't use string for the keys. not everyone use them but 🤷

and having the context was meant to easily be able to log the error with a PSR LoggerInterface afaict


/**
* Returns the underlying/previous error.
*/
public function getPrevious(): ?ErrorInterface;

/**
* Creates a new instance with additional context.
*
* @param array<string, mixed> $context
*/
public function withContext(array $context): self;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure that should be part of the interface

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, should be:
1, can be used for context propagation through app layers (like add req ID in middlewares), 2, it matches PSR-7 withXYZ() pattern, 3, it required for func error transformation (mapError) to preserve context chain.

Minimal example:

$result = myOperation()
    ->mapError(fn($error) => $error->withContext(['step' => 'validation']))
    ->mapError(fn($error) => $error->withContext(['service' => 'user-api']));

// So final error contains both 'step' and 'service' in context.
if ($result->isFailure()) {
    print_r($result->getError()->getContext());
    // ['step' => 'validation', 'service' => 'user-api']
}

Without it, error context is frozen at creation => breaking composability

?

}
```

### 3.2 `ResultInterface`

```php
<?php

namespace Psr\Result;

use Psr\Error\ErrorInterface;

/**
* Represents the result of an operation.
*
* Execution rules:
* - If the result is a failure, `map()` and `then()` MUST NOT call their callbacks.
* - If the result is a success, `mapError()` MUST NOT call its callback.
* - `then()` MUST NOT wrap results; it must flatten them.
*
* @template TValue
* @template TError of ErrorInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest allowing to use anything as error types, forcing to have a Throwable is not that great imo (and a bit costly because creating an exception is not as cheap as creating simpler objects)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, allowing “anything” as error types is not generic, it is untyped... a PSR(mine) that allows mixed errors is useless for composition.

That return type allows to use codes, messages, context and avoids mixed & arrays-of-arrays hell.
Errors MUST be plain value objects. (?)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ErrorInterface is not really a plain value object as it extends Throwable

and enforcing ErrorInterface also prevents reusing anything that isn't compatible with this PSR

also, not enforcing anything is not completely untyped, you can still have the template type and benefit from cases where the result do implement ErrorInterface

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, other PSRs:
PSR-3 LoggerInterface expects Throwable in $context['exception']
PSR-14 listeners may receive errors as Throwable
PSR-18 HTTP clients throw exceptions that implement Throwable
...
So if ErrorInterface does not extend Throwable, our result errors can NOT be passed directly to these standards. Or it's wrong?

From my meta document: Integrate with existing PSRs (PSR-3 logging, PSR-14 events)

ErrorInterface is not really a plain value object as it extends Throwable

It is, because no stack trace unless you call getTrace()
AFAIK, in PHP core, stack traces are only captured when an exception is instantiated, NOT merely because a class implements Throwable

class ValidationError implements \Throwable
{
    public function getTrace(): array { return []; }      // <--- zero-cost like oxygen :D

No stack trace is ever generated, no internal PHP exception machinery is triggered, so object is a true plain value object
Only if you extend Exception (or call debug_backtrace()) do you pay the cost

also, not enforcing anything is not completely untyped, you can still have the template type and benefit from cases where the result do implement ErrorInterface

And not enforcing devs to use ErrorInterface = no standard contract
That defeats the entire purpose of a PSR :/

But I'm still okay with you to remove that...
?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, other PSRs:
PSR-3 LoggerInterface expects Throwable in $context['exception']
PSR-14 listeners may receive errors as Throwable
PSR-18 HTTP clients throw exceptions that implement Throwable

well, for PSR-3, it says:

If an Exception object is passed in the context data, it MUST be in the 'exception' key. Logging exceptions is a common pattern and this allows implementors to extract a stack trace from the exception when the log backend supports it. Implementors MUST still verify that the 'exception' key is actually an Exception before using it as such, as it MAY contain anything.

so, if the error type is not an exception you can log it in another field, or even in the exception field if you're feeling adventurous as implementation of logger must check that it's indeed an exception before using it as such

for psr-14, it mentions throwing exceptions, so yeah if you wrap it in a result with try() the error type will be an exception. doesn't need to be forced to be an exception in Result for that to work

and same for psr-18, it's emitting exceptions, not taking results like things as input either

class ValidationError implements \Throwable

you can't do that it's not valid PHP code : https://3v4l.org/ekl3s#vnull
you have to extend an Exception to implement Throwable

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can't do that it's not valid PHP code : https://3v4l.org/ekl3s#vnull

Yes I know, I just wanted to use pseudocode to show what I mean

So remove Throwable from ErrorInterface ? it breaks nothing

*/
interface ResultInterface
{
/**
* Returns true if the operation was successful.
*/
public function isSuccess(): bool;

/**
* Returns true if the operation failed.
*/
public function isFailure(): bool;

/**
* Returns the success value
*
* @return TValue
* @throws \BadMethodCallException If result is a failure.
*/
public function getValue(): mixed;

/**
* Returns the error if operation failed.
*
* @return TError|null
*/
public function getError(): ?ErrorInterface;
Comment on lines +114 to +116
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is to avoid null returns, then wouldn't it make more sense for this to be:

Suggested change
* @return TError|null
*/
public function getError(): ?ErrorInterface;
* @return TError
* @throws \BadMethodCallException If the result is a success.
*/
public function getError(): ErrorInterface;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I took this design from Rust Result::err() -> Option<E> and Kotlin Result.exceptionOrNull()
If the Result is a failure/error, they return error stuff... but if the Result is a success/ok, they return null

So my getError() must return null because:

1- It is only valid to call when isFailure() is true
2- Returning null on success prevents consumers to handle a meaningless err object
3- Removing null would violate the tagged union pattern: you must check isFailure() first

Note that this PSR avoids null for operation outcomes, not for accessor methods GUARDED by these.

Kotlin docs: exceptionOrNull() returns the encapsulated Throwable exception if this instance represents failure or null if it is success. This function is a shorthand for fold(onSuccess = { null }, onFailure = { it })

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it stays |null or moves to mixed it adds the possibility of having SuccessResultInterface and ErrorResultInterface where SuccessResultInterface replaces getError with public function getError(): null and isError with public function isError(): false. But that might be overly cumbersome.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaces getError with public function getError(): null

there's also the option to have : never return types on some methods depending on SuccessResultInterface / ErrorResultInterface

I took advantage of that in my Result implementation attempt : https://github.com/texthtml/maybe/tree/main/src/Result and the result is quite nice, Psalm, PHPStorm and mago can give good insight. for exemple, combined with those annotations: @psalm-assert-if-true Result\Success<T> $this on isSuccess() let the static analysis tool detect dead code, warn when trying to unwrap an error that it'll always throw, etc.


/**
* Applies a transformation to the success value.
*
* Called only if the result is successful.
* Failures are propagated unchanged.
*
* @template TNew
* @param callable(TValue): TNew $transform
* A callable that receives the success value and returns a new value.
* @return ResultInterface<TNew, TError>
*/
public function map(callable $transform): ResultInterface;

/**
* Applies a transformation to the error.
*
* Called only if the result is a failure.
* Success values are propagated unchanged.
*
* @template TNewError of ErrorInterface
* @param callable(TError): TNewError $transform
* A callable that receives the error and returns a new error.
* @return ResultInterface<TValue, TNewError>
*/
public function mapError(callable $transform): ResultInterface;

/**
* Chains another operation that returns a Result.
*
* Called only if the result is successful.
* The returned Result is flattened (no nesting).
*
* @template TNew
* @param callable(TValue): ResultInterface<TNew, TError> $operation
* A callable that receives the success value and returns a Result.
* @return ResultInterface<TNew, TError>
*/
public function then(callable $operation): ResultInterface;

/**
* Resolves the result into a single value.
*
* Exactly one callback is called.
* This terminates the Result pipeline.
*
* @template TReturn
* @param callable(TValue): TReturn $onSuccess
* @param callable(TError): TReturn $onFailure
* @return TReturn
*/
public function fold(callable $onSuccess, callable $onFailure): mixed;

/**
* Returns the success value or a default if failed.
*
* Does not expose the error.
*
* @param TValue $default
* The value to return if the result is a failure.
* @return TValue
*/
public function getValueOr(mixed $default): mixed;
}
```

### 3.3 `ResultFactoryInterface`

```php
<?php

namespace Psr\Result;

use Psr\Error\ErrorInterface;

interface ResultFactoryInterface
{
/**
* Create a successful result.
*
* @template T
* @param T $value
* @return ResultInterface<T, ErrorInterface>
*/
public function success(mixed $value): ResultInterface;

/**
* Create a failed result.
*
* @template E of ErrorInterface
* @param E $error
* @return ResultInterface<mixed, E>
*/
public function failure(ErrorInterface $error): ResultInterface;

/**
* Create result from a callable that may throw.
*
* @template T
* @param callable(): T $operation
* @param callable(\Throwable): ErrorInterface $errorMapper
* @return ResultInterface<T, ErrorInterface>
*/
public function try(callable $operation, callable $errorMapper): ResultInterface;
}
```

## 4. Usage Examples

### 4.1 Basic Usage

```php
$result = $userRepository->findById($id);

if ($result->isSuccess()) {
$user = $result->getValue();
echo "Found: " . $user->getName();
} else {
$error = $result->getError();
logError($error->getCode(), $error->getContext());
}
```

### 4.2 Functional paradigm

```php
$email = $userRepository->findById($id)
->map(fn($user) => $user->getEmail())
->getValueOr('default@example.com');
```

### 4.3 Chaining Operations

```php
$result = $validator->validate($input)
->then(fn($v) => $repository->save($v))
->then(fn($e) => $notifier->notifyCreated($e))
->mapError(fn($err) => new PublicError($err->getMessage()));

// At the end...
if ($result->isSuccess()) {
$finalEntity = $result->getValue(); // From notifier.
} else {
$publicError = $result->getError(); // Already transformed to PublicError.
}
```