Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
52 / 52 |
|
100.00% |
18 / 18 |
CRAP | |
100.00% |
1 / 1 |
| Result | |
100.00% |
52 / 52 |
|
100.00% |
18 / 18 |
35 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| ok | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fail | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| isOk | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isFail | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getErrors | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getErrorMessages | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| then | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| transform | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| flatMap | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| orThen | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| otherwise | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| ensure | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
| unwrap | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| default | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| unwrapOrHandle | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| throwOnFail | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| combine | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Eco; |
| 6 | |
| 7 | use Eco\Exceptions\ResultException; |
| 8 | |
| 9 | /** |
| 10 | * Represents the result of an operation that may fail without throwing an exception. |
| 11 | * |
| 12 | * Use this for validations and business rules where failure is an expected, |
| 13 | * recoverable outcome — not an exceptional one. |
| 14 | * |
| 15 | * Prefer {@see Result::fail()} over throwing exceptions when: |
| 16 | * - The user may have provided invalid input |
| 17 | * - A business rule was not satisfied |
| 18 | * - A resource was not found based on user-provided data |
| 19 | * |
| 20 | * Prefer throwing exceptions when: |
| 21 | * - A database or external service is unavailable |
| 22 | * - An impossible/invariant state is reached (likely a bug) |
| 23 | * |
| 24 | * ---------------------------------------------------------------------------- |
| 25 | * Pipeline overview |
| 26 | * ---------------------------------------------------------------------------- |
| 27 | * |
| 28 | * Method Runs when Alters Result? Purpose |
| 29 | * --------------- ----------- ---------------- ------------------------------ |
| 30 | * then() ok no Side-effect with the value |
| 31 | * orThen() fail no Side-effect with the errors |
| 32 | * transform() ok yes — new value Transform the carried value |
| 33 | * flatMap() ok yes — new Result Chain a Result-returning op |
| 34 | * otherwise() fail yes — new Result Recover from failure |
| 35 | * |
| 36 | * ---------------------------------------------------------------------------- |
| 37 | * Unwrap overview |
| 38 | * ---------------------------------------------------------------------------- |
| 39 | * |
| 40 | * Method Returns on ok Returns on fail |
| 41 | * ----------------------- --------------- ------------------------------------ |
| 42 | * unwrap() value throws ResultException |
| 43 | * default($default) value $default |
| 44 | * unwrapOrHandle($fn) value calls $fn(errors), returns null |
| 45 | * |
| 46 | * @template T The type of the successful value |
| 47 | */ |
| 48 | final class Result |
| 49 | { |
| 50 | private bool $success; |
| 51 | |
| 52 | /** @var T */ |
| 53 | private mixed $value; |
| 54 | |
| 55 | /** @var Error[] */ |
| 56 | private array $errors; |
| 57 | |
| 58 | private function __construct(bool $success, mixed $value = null, array $errors = []) |
| 59 | { |
| 60 | $this->success = $success; |
| 61 | $this->value = $value; |
| 62 | /** @var Error[] $errors */ |
| 63 | $this->errors = $errors; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Creates a successful Result carrying the given value. |
| 68 | * |
| 69 | * When called with no arguments, creates a successful Result with no value. |
| 70 | * Use this for operations that succeed without producing a meaningful return, |
| 71 | * such as deletes, updates, or fire-and-forget actions. |
| 72 | * |
| 73 | * ```php |
| 74 | * return Result::ok($user); |
| 75 | * return Result::ok(['id' => 1, 'name' => 'Ana']); |
| 76 | * |
| 77 | * // No value: |
| 78 | * function deleteUser(int $id): Result |
| 79 | * { |
| 80 | * $this->repo->delete($id); |
| 81 | * return Result::ok(); |
| 82 | * } |
| 83 | * ``` |
| 84 | * |
| 85 | * @param T $value |
| 86 | * @return self<T> |
| 87 | */ |
| 88 | public static function ok(mixed $value = null): self |
| 89 | { |
| 90 | return new self(true, $value); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Creates a failed Result carrying one or more errors. |
| 95 | * |
| 96 | * Plain strings are automatically wrapped in {@see Error::generic()}. |
| 97 | * Pass typed {@see Error} instances for richer, machine-readable errors. |
| 98 | * |
| 99 | * ```php |
| 100 | * Result::fail('Something went wrong.'); |
| 101 | * Result::fail(Error::validation('email', 'Invalid format.')); |
| 102 | * Result::fail( |
| 103 | * Error::validation('name', 'Required.'), |
| 104 | * Error::validation('email', 'Invalid format.'), |
| 105 | * ); |
| 106 | * ``` |
| 107 | * |
| 108 | * @param string|Error ...$errors |
| 109 | * @return self<never> |
| 110 | */ |
| 111 | public static function fail(string|Error ...$errors): self |
| 112 | { |
| 113 | $normalized = array_map( |
| 114 | fn($e) => $e instanceof Error ? $e : Error::generic($e), |
| 115 | $errors |
| 116 | ); |
| 117 | |
| 118 | /** @var self<never> $result */ |
| 119 | $result = new self(false, null, $normalized); |
| 120 | return $result; |
| 121 | } |
| 122 | |
| 123 | /** Returns true when the operation succeeded. */ |
| 124 | public function isOk(): bool |
| 125 | { |
| 126 | return $this->success; |
| 127 | } |
| 128 | |
| 129 | /** Returns true when the operation failed. */ |
| 130 | public function isFail(): bool |
| 131 | { |
| 132 | return !$this->success; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Returns all errors carried by this Result. |
| 137 | * Returns an empty array when the Result is successful. |
| 138 | * |
| 139 | * @return Error[] |
| 140 | */ |
| 141 | public function getErrors(): array |
| 142 | { |
| 143 | return $this->errors; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Returns every error message as a plain string array. |
| 148 | * Useful for quick serialization or display. |
| 149 | * |
| 150 | * ```php |
| 151 | * $result->getErrorMessages(); // ['Required.', 'Invalid format.'] |
| 152 | * ``` |
| 153 | * |
| 154 | * @return string[] |
| 155 | */ |
| 156 | public function getErrorMessages(): array |
| 157 | { |
| 158 | return array_map(fn(Error $e) => $e->message, $this->errors); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Executes a side-effect with the successful value, without altering it. |
| 163 | * Skipped entirely when the Result is a failure. |
| 164 | * |
| 165 | * Use for logging, caching, or triggering events mid-pipeline |
| 166 | * when you do not want to change the carried value: |
| 167 | * |
| 168 | * ```php |
| 169 | * getUserById($id) |
| 170 | * ->then(fn($user) => $logger->info("User loaded: {$user->name}")) |
| 171 | * ->then(fn($user) => $cache->store("user:{$id}", $user)) |
| 172 | * ->transform(fn($user) => $user->toArray()); |
| 173 | * ``` |
| 174 | * |
| 175 | * @param callable(T): void $fn |
| 176 | * @return self<T> |
| 177 | */ |
| 178 | public function then(callable $fn): self |
| 179 | { |
| 180 | if ($this->isOk()) { |
| 181 | $fn($this->value); |
| 182 | } |
| 183 | |
| 184 | return $this; |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Transforms the successful value using the given callback. |
| 189 | * Returns a new Result carrying the transformed value. |
| 190 | * Skipped entirely when the Result is a failure. |
| 191 | * |
| 192 | * Unlike {@see flatMap()}, the callback returns a plain value — not a Result. |
| 193 | * Use {@see flatMap()} when the transformation itself can fail. |
| 194 | * |
| 195 | * ```php |
| 196 | * getUserById($id) |
| 197 | * ->transform(fn(UserDTO $user) => $user->name) |
| 198 | * ->transform(fn(string $name) => strtoupper($name)); |
| 199 | * ``` |
| 200 | * |
| 201 | * @param callable(T): mixed $fn |
| 202 | * @return self |
| 203 | */ |
| 204 | public function transform(callable $fn): self |
| 205 | { |
| 206 | if ($this->isFail()) { |
| 207 | return $this; |
| 208 | } |
| 209 | |
| 210 | return self::ok($fn($this->value)); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Chains an operation that itself returns a Result. |
| 215 | * Short-circuits on the first failure — subsequent steps are skipped. |
| 216 | * |
| 217 | * Unlike {@see transform()}, the callback must return a Result. |
| 218 | * Use this when the next step can also fail. |
| 219 | * |
| 220 | * Use {@see Result::combine()} instead when steps are independent |
| 221 | * and you want to collect all errors at once. |
| 222 | * |
| 223 | * ```php |
| 224 | * Result::ok($input) |
| 225 | * ->flatMap(fn($input) => validate($input)) |
| 226 | * ->flatMap(fn($input) => persist($input)) |
| 227 | * ->transform(fn($user) => new UserDTO($user)); |
| 228 | * ``` |
| 229 | * |
| 230 | * @param callable(T): Result $fn |
| 231 | * @return self |
| 232 | */ |
| 233 | public function flatMap(callable $fn): self |
| 234 | { |
| 235 | if ($this->isFail()) { |
| 236 | return $this; |
| 237 | } |
| 238 | |
| 239 | return $fn($this->value); |
| 240 | } |
| 241 | |
| 242 | /** |
| 243 | * Executes a side-effect with the errors, without altering the Result. |
| 244 | * Skipped entirely when the Result is successful. |
| 245 | * |
| 246 | * The mirror of {@see then()} for the failure path. |
| 247 | * Use for logging or observing errors mid-pipeline: |
| 248 | * |
| 249 | * ```php |
| 250 | * getUserById($id) |
| 251 | * ->orThen(fn($errors) => $logger->warning('User not found', $errors)) |
| 252 | * ->otherwise(fn($errors) => Result::ok(UserDTO::guest())); |
| 253 | * ``` |
| 254 | * |
| 255 | * @param callable(Error[]): void $fn |
| 256 | * @return self |
| 257 | */ |
| 258 | public function orThen(callable $fn): self |
| 259 | { |
| 260 | if ($this->isFail()) { |
| 261 | $fn($this->errors); |
| 262 | } |
| 263 | |
| 264 | return $this; |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Attempts to recover from a failure by returning a new Result. |
| 269 | * Skipped entirely when the Result is successful. |
| 270 | * |
| 271 | * The callback receives the current errors and must return a Result — |
| 272 | * either a recovered success or a (possibly different) failure. |
| 273 | * |
| 274 | * The mirror of {@see flatMap()} for the failure path. |
| 275 | * |
| 276 | * ```php |
| 277 | * fetchFromCache($key) |
| 278 | * ->otherwise(fn($errors) => fetchFromDatabase($key)) |
| 279 | * ->otherwise(fn($errors) => Result::ok($defaultValue)); |
| 280 | * ``` |
| 281 | * |
| 282 | * @param callable(Error[]): self $fn |
| 283 | * @return self |
| 284 | */ |
| 285 | public function otherwise(callable $fn): self |
| 286 | { |
| 287 | if ($this->isFail()) { |
| 288 | return $fn($this->errors); |
| 289 | } |
| 290 | |
| 291 | return $this; |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Validates the successful value against multiple conditions at once, |
| 296 | * collecting every error from every failing rule before returning. |
| 297 | * Skipped entirely when the Result is already a failure. |
| 298 | * |
| 299 | * The first failure — ensure() always evaluates every rule, making it |
| 300 | * ideal when you want to surface all violations in a single pass. |
| 301 | * |
| 302 | * Each entry in $rules must be an array with exactly two elements: |
| 303 | * - a callable that receives the value and returns bool |
| 304 | * - an Error or string to use when that condition fails |
| 305 | * |
| 306 | * ```php |
| 307 | * Result::ok($name) |
| 308 | * ->ensure([ |
| 309 | * [fn($name) => !empty($name), Error::validation('name', 'Required.')], |
| 310 | * [fn($name) => strlen($name) <= 100, Error::validation('name', 'Too long.')], |
| 311 | * [fn($name) => ctype_alpha($name), Error::validation('name', 'Letters only.')], |
| 312 | * ]) |
| 313 | * ->transform(fn($name) => StrHandler::sanitize($name)); |
| 314 | * ``` |
| 315 | * |
| 316 | * @param array<array{callable(T): bool, Error|string}> $rules |
| 317 | * @return self<T> |
| 318 | */ |
| 319 | public function ensure(array $rules): self |
| 320 | { |
| 321 | if ($this->isFail()) { |
| 322 | return $this; |
| 323 | } |
| 324 | |
| 325 | $errors = []; |
| 326 | |
| 327 | foreach ($rules as [$condition, $error]) { |
| 328 | if (!$condition($this->value)) { |
| 329 | $errors[] = $error instanceof Error ? $error : Error::generic($error); |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | return empty($errors) ? $this : new self(false, null, $errors); |
| 334 | } |
| 335 | |
| 336 | /** |
| 337 | * Returns the value, or throws a {@see ResultException} if the Result is a failure. |
| 338 | * |
| 339 | * Use only when you are certain the Result is successful — for instance, |
| 340 | * right after a successful {@see combine()} check. Calling this on a |
| 341 | * failure is a programming error. |
| 342 | * |
| 343 | * ```php |
| 344 | * $user = getUserById($id)->unwrap(); // throws ResultException if not found |
| 345 | * ``` |
| 346 | * |
| 347 | * @throws ResultException |
| 348 | * @return T |
| 349 | */ |
| 350 | public function unwrap(): mixed |
| 351 | { |
| 352 | if ($this->isFail()) { |
| 353 | throw new ResultException($this->errors); |
| 354 | } |
| 355 | |
| 356 | return $this->value; |
| 357 | } |
| 358 | |
| 359 | /** |
| 360 | * Returns the value if successful; otherwise returns the given default. |
| 361 | * |
| 362 | * Use when failure has an acceptable fallback and no handling is needed. |
| 363 | * |
| 364 | * ```php |
| 365 | * $name = getUserById($id) |
| 366 | * ->transform(fn($user) => $user->name) |
| 367 | * ->default('Anonymous'); |
| 368 | * ``` |
| 369 | * |
| 370 | * @param T $default |
| 371 | * @return T |
| 372 | */ |
| 373 | public function default(mixed $default): mixed |
| 374 | { |
| 375 | return $this->isOk() ? $this->value : $default; |
| 376 | } |
| 377 | |
| 378 | /** |
| 379 | * Returns the value if successful; otherwise calls the given callback |
| 380 | * with the errors and returns null. |
| 381 | * |
| 382 | * The callback is responsible for deciding what happens on failure — |
| 383 | * return an HTTP response, throw, log, redirect, etc. |
| 384 | * If execution should not continue with null, always exit inside the callback. |
| 385 | * |
| 386 | * ```php |
| 387 | * $user = getUserById($id) |
| 388 | * ->unwrapOrHandle(function (array $errors): void { |
| 389 | * http_response_code(404); |
| 390 | * echo json_encode(['errors' => $errors]); |
| 391 | * exit; |
| 392 | * }); |
| 393 | * ``` |
| 394 | * |
| 395 | * @param callable(Error[]): void $fn |
| 396 | * @return T|null |
| 397 | */ |
| 398 | public function unwrapOrHandle(callable $fn): mixed |
| 399 | { |
| 400 | if ($this->isFail()) { |
| 401 | $fn($this->errors); |
| 402 | return null; |
| 403 | } |
| 404 | |
| 405 | return $this->value; |
| 406 | } |
| 407 | |
| 408 | /** |
| 409 | * Returns a ready-made callback for {@see unwrapOrHandle()} that converts |
| 410 | * a failed Result into a {@see ResultException}. |
| 411 | * |
| 412 | * Useful when you want to re-enter exception-based error handling, |
| 413 | * for example inside a context that already has a global exception handler. |
| 414 | * |
| 415 | * ```php |
| 416 | * $user = getUserById($id)->unwrapOrHandle(Result::throwOnFail()); |
| 417 | * ``` |
| 418 | * |
| 419 | * @return callable(Error[]): never |
| 420 | */ |
| 421 | public static function throwOnFail(): callable |
| 422 | { |
| 423 | return function (array $errors): void { |
| 424 | /** @var Error[] $errors */ |
| 425 | throw new ResultException($errors); |
| 426 | }; |
| 427 | } |
| 428 | |
| 429 | /** |
| 430 | * Runs all given Results and collects every error from each failure. |
| 431 | * Returns ok carrying $value only when all Results succeed. |
| 432 | * |
| 433 | * Unlike {@see flatMap()}, which short-circuits on the first failure, |
| 434 | * combine() always evaluates every Result — making it ideal for form |
| 435 | * validation where you want to show all errors at once. |
| 436 | * |
| 437 | * Pass the value to carry forward as the first argument — typically |
| 438 | * the original input being validated. On failure, the value is discarded. |
| 439 | * |
| 440 | * ```php |
| 441 | * Result::ok($data) |
| 442 | * ->flatMap(fn($data) => Result::combine($data, |
| 443 | * !empty($data['name']) ? Result::ok() : Result::fail(Error::validation('name', 'Required.')), |
| 444 | * !empty($data['email']) ? Result::ok() : Result::fail(Error::validation('email', 'Required.')), |
| 445 | * )) |
| 446 | * ->transform(fn($data) => new User($data['name'], $data['email'])); |
| 447 | * ``` |
| 448 | * |
| 449 | * When no value needs to be carried (standalone validation), pass null: |
| 450 | * ```php |
| 451 | * Result::combine(null, $resultA, $resultB); |
| 452 | * ``` |
| 453 | * |
| 454 | * @param mixed $value The value to carry on success. |
| 455 | * @param Result ...$results |
| 456 | * @return self |
| 457 | */ |
| 458 | public static function combine(mixed $value, Result ...$results): self |
| 459 | { |
| 460 | $allErrors = []; |
| 461 | |
| 462 | foreach ($results as $result) { |
| 463 | if ($result->isFail()) { |
| 464 | $allErrors = array_merge($allErrors, $result->getErrors()); |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | return empty($allErrors) ? self::ok($value) : new self(false, null, $allErrors); |
| 469 | } |
| 470 | } |