-
Notifications
You must be signed in to change notification settings - Fork 2.8k
New PSR for "Error and Result Handling" interfaces #1344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Signed-off-by: Yousha Aleayoub <yousha.a@hotmail.com>
|
Hi @Yousha, thank you for the proposal. As a first step would be to form a Working Group, you can see here how you can proceed : https://www.php-fig.org/bylaws/psr-workflow/ |
|
Pre-Draft posted in Google Groups: https://groups.google.com/g/php-fig/c/OpEuvGERM5A/m/NgKT46EVBgAJ |
proposed/error-result.md
Outdated
| $result = $validator->validate($input) | ||
| ->then(fn($validated) => $repository->save($validated)) | ||
| ->then(fn($entity) => $notifier->notifyCreated($entity)) | ||
| ->mapError(fn($error) => new PublicError($error->getMessage())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an incomplete example. Usually you need to do something with the error, and the error flow is different from the success flow. Do I get the full example right?
$result = $validator->validate($input)
->then(fn($validated) => $repository->save($validated))
->then(fn($entity) => $notifier->notifyCreated($entity))
->mapError(fn($error) => new PublicError($error->getMessage()));
if ($result instanceof PublicError) {
// handle error page
}vs usual:
$result = $validator->validate($input);
if ($result->isFailure()) {
$publicError = new PublicError($result->getError()->getMessage());
// handle error page
}
$entity = $repository->save($input);
$notifier->notifyCreated($entity);There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes you are right, example is just minimal. Full version:
$result = $validator->validate($input)
->then(fn($validated) => $repository->save($validated))
->then(fn($entity) => $notifier->notifyCreated($entity))
->mapError(fn($error) => new PublicError($error->getMessage()));
if ($result->isFailure()) {
return $response->error(
$result->getError()
);
}
return $response->success(
$result->getValue()
);
Let me add 2 versions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But that is not any better. It is functional style just for the sake of it and makes the whole piece less readable without any benefits.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your point is true about readability, this PSR also supports both styles. Use any style you want :-)
Branching/Simple:
$result = $validator->validate($input);
if ($result->isFailure()) {
return new PublicError($result->getError()->getMessage());
}
$entity = $repository->save($input);
$notifier->notifyCreated($entity);Or functional chaining:
$result = $validator->validate($input)
->then(fn($v) => $repository->save($v))
->then(fn($e) => $notifier->notifyCreated($e))
->mapError(fn($err) => new PublicError($err->getMessage()));Note that functional chaining isn't just for style, it prevents invalid states.... since chaining enforces data validity at EACH step, no risk of using unvalidated or unsaved values.
Co-authored-by: Alexander Makarov <sam@rmcreative.ru>
Co-authored-by: Alexander Makarov <sam@rmcreative.ru>
Add Execution rules comment at interface level. Improve methods doc comments.
Move Backward Compatibility section to meta document.
Add Backward Compatibility section.
Move section to meta document.
Add Error Classification section.
Improve example.
Add Typing and Generics section. Add group address.
Add PSR-3 notes.
Improve docblocks for PSR-3.
| * | ||
| * @param array<string, mixed> $context | ||
| */ | ||
| public function withContext(array $context): self; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
?
| * - `then()` MUST NOT wrap results; it must flatten them. | ||
| * | ||
| * @template TValue | ||
| * @template TError of ErrorInterface |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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. (?)
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 :DNo 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...
?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea of this PSR, but I think it would make a lot more sense to adopt the Either pattern rather than what is described here, which (AFAICT) is not based on any established patterns in programming.
| /** | ||
| * Represents structured error information. | ||
| */ | ||
| interface ErrorInterface extends \Throwable |
There was a problem hiding this comment.
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();There was a problem hiding this comment.
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.
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Throwableis an interface that can't be implemented directly by PHP code. so you have to extend an existing exception class to implement it- 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
There was a problem hiding this comment.
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?
| * | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function getContext(): array; |
There was a problem hiding this comment.
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);There was a problem hiding this comment.
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
| * @return TError|null | ||
| */ | ||
| public function getError(): ?ErrorInterface; |
There was a problem hiding this comment.
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:
| * @return TError|null | |
| */ | |
| public function getError(): ?ErrorInterface; | |
| * @return TError | |
| * @throws \BadMethodCallException If the result is a success. | |
| */ | |
| public function getError(): ErrorInterface; |
There was a problem hiding this comment.
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 })
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| ```php | ||
| <?php | ||
|
|
||
| namespace Psr\Error; |
There was a problem hiding this comment.
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:
| namespace Psr\Error; | |
| namespace Psr\Result; |
There was a problem hiding this comment.
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.
?
@shadowhand (noted to meta document) |
This PR adds standard interfaces for type-safe operation results (success/failure) with structured errors.
It solves inconsistent error reporting across libraries while complemeting PHP's type system. Inspired by Rust/Swift Result types, integrates with existing PSRs.
Contracts:
Use cases: