Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
Result
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
18 / 18
35
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 ok
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 isOk
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorMessages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 then
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 transform
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 flatMap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 orThen
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 otherwise
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 ensure
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 unwrap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 default
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 unwrapOrHandle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 throwOnFail
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 combine
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Eco;
6
7use 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 */
48final 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}