diff --git a/README.md b/README.md index a0d2f27..ee3f945 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It centers on three public entry points: The library also includes convenience APIs for common result workflows: - `FailOr.Success(...)` and `FailOr.Fail(...)` for construction -- `Then(...)`, `ThenAsync(...)`, `ThenDo(...)`, and `ThenDoAsync(...)` for chaining and success-side effects +- `Then(...)`, `ThenAsync(...)`, `ThenEnsure(...)`, `ThenEnsureAsync(...)`, `ThenDo(...)`, and `ThenDoAsync(...)` for chaining and success-side effects - `Match(...)` and `MatchFirst(...)` for branching - `Zip(...)` for aggregating multiple results - `Combine(...)` for choosing a preferred success with fallback @@ -146,7 +146,7 @@ if (result.IsFailure) } ``` -You can also translate the exception into a custom repo-native result: +You can also translate the exception into a custom repository-native result: ```csharp using FailOr; @@ -157,6 +157,38 @@ var result = FailOr.Success("42x") exception => Failure.General($"Mapping failed: {exception.Message}")); ``` +### Validate success values with `ThenEnsure` + +Use `ThenEnsure` when the next step should validate the current success and keep that original value flowing when validation succeeds. + +```csharp +using FailOr; + +var result = FailOr.Success(10) + .ThenEnsure(value => + value >= 0 + ? FailOr.Success(true) + : FailOr.Fail(Failure.General("Value must be non-negative."))) + .Then(value => value + 5); + +var finalValue = result.UnsafeUnwrap(); // 15 +``` + +Async validation helpers are also available: + +```csharp +using FailOr; + +var result = await FailOr.Success(10) + .ThenEnsureAsync(async value => + { + await Task.Delay(10); + return value % 2 == 0 + ? FailOr.Success(true) + : FailOr.Fail(Failure.General("Value must be even.")); + }); +``` + ### Run success-side effects with `ThenDo` Use `ThenDo` when you want to observe a success without changing the flowing result. diff --git a/docs/api-reference.md b/docs/api-reference.md index ec9d953..393b18a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -267,6 +267,25 @@ var result = FailOr.Success("42") .Then(ParseNumber); ``` +### Validate with another `FailOr` and preserve the success + +```csharp +public FailOr ThenEnsure(Func> ensure) +``` + +Intent: +Run a validation step that can fail while keeping the original success value unchanged when validation succeeds. + +Example: + +```csharp +var result = FailOr.Success(10) + .ThenEnsure(value => + value >= 0 + ? FailOr.Success(true) + : FailOr.Fail(Failure.General("Value must be non-negative."))); +``` + ### Map asynchronously ```csharp @@ -354,6 +373,28 @@ var result = await FailOr.Success("42") .ThenAsync(ParseNumberAsync); ``` +### Validate asynchronously and preserve the success + +```csharp +public Task> ThenEnsureAsync(Func>> ensureAsync) +``` + +Intent: +Run an asynchronous validation step that can fail while keeping the original success value unchanged when validation succeeds. + +Example: + +```csharp +var result = await FailOr.Success(10) + .ThenEnsureAsync(async value => + { + await Task.Delay(10); + return value % 2 == 0 + ? FailOr.Success(true) + : FailOr.Fail(Failure.General("Value must be even.")); + }); +``` + ### Run a side effect and preserve the success ```csharp diff --git a/src/FailOr/FailOrT.Then.cs b/src/FailOr/FailOrT.Then.cs index ccc210e..700ec61 100644 --- a/src/FailOr/FailOrT.Then.cs +++ b/src/FailOr/FailOrT.Then.cs @@ -53,6 +53,29 @@ public FailOr Then(Func> bind) return source.IsFailure ? Fail(source) : bind(source.UnsafeUnwrap()); } + /// + /// Validates a successful value with another check while preserving the original success. + /// + /// The validation function to apply when the source is successful. + /// + /// The original success unchanged when the validation succeeds, the validation failures when it fails, + /// or the original failures when the source is failed. + /// + /// + /// Thrown when is . + /// + /// + /// + /// var next = result.ThenEnsure(x => x > 0 ? FailOr.Success(true) : FailOr.Fail<bool>(Failure.General("Must be positive"))); + /// + /// + public FailOr ThenEnsure(Func> ensure) + { + ArgumentNullException.ThrowIfNull(ensure); + + return source.IsFailure ? source : ThenEnsureCore(source.UnsafeUnwrap(), ensure); + } + /// /// Asynchronously maps a successful value to a new successful result. /// @@ -103,6 +126,33 @@ Func>> bindAsync : ThenBindAsync(source.UnsafeUnwrap(), bindAsync); } + /// + /// Asynchronously validates a successful value with another check while preserving the original success. + /// + /// The asynchronous validation function to apply when the source is successful. + /// + /// A task producing the original success unchanged when the validation succeeds, the validation failures when it fails, + /// or the original failures when the source is failed. + /// + /// + /// Thrown when is or returns . + /// + /// + /// + /// var next = await result.ThenEnsureAsync(x => Task.FromResult(x > 0 ? FailOr.Success(true) : FailOr.Fail<bool>(Failure.General("Must be positive")))); + /// + /// + public Task> ThenEnsureAsync( + Func>> ensureAsync + ) + { + ArgumentNullException.ThrowIfNull(ensureAsync); + + return source.IsFailure + ? Task.FromResult(source) + : ThenEnsureAsyncCore(source.UnsafeUnwrap(), ensureAsync); + } + /// /// Runs a side effect for a successful value while preserving the original result. /// @@ -315,6 +365,30 @@ public Task> Then(Func> bind) return ThenCore(sourceTask, source => Task.FromResult(source.Then(bind))); } + /// + /// Validates the successful value of a task-wrapped result with another check while preserving the original success. + /// + /// The validation function to apply when the awaited source is successful. + /// + /// A task producing the original success unchanged when the validation succeeds, the validation failures when it fails, + /// or the original failures when the awaited source is failed. + /// + /// + /// Thrown when the awaited source task or is . + /// + /// + /// + /// var next = await resultTask.ThenEnsure(x => x > 0 ? FailOr.Success(true) : FailOr.Fail<bool>(Failure.General("Must be positive"))); + /// + /// + public Task> ThenEnsure(Func> ensure) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(ensure); + + return ThenCore(sourceTask, source => Task.FromResult(source.ThenEnsure(ensure))); + } + /// /// Asynchronously maps the successful value of a task-wrapped result to a new successful result. /// @@ -363,6 +437,33 @@ Func>> bindAsync return ThenCore(sourceTask, source => source.ThenAsync(bindAsync)); } + /// + /// Asynchronously validates the successful value of a task-wrapped result with another check while preserving the original success. + /// + /// The asynchronous validation function to apply when the awaited source is successful. + /// + /// A task producing the original success unchanged when the validation succeeds, the validation failures when it fails, + /// or the original failures when the awaited source is failed. + /// + /// + /// Thrown when the awaited source task or is , + /// or when returns . + /// + /// + /// + /// var next = await resultTask.ThenEnsureAsync(x => Task.FromResult(x > 0 ? FailOr.Success(true) : FailOr.Fail<bool>(Failure.General("Must be positive")))); + /// + /// + public Task> ThenEnsureAsync( + Func>> ensureAsync + ) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(ensureAsync); + + return ThenCore(sourceTask, source => source.ThenEnsureAsync(ensureAsync)); + } + /// /// Runs a side effect for the successful value of a task-wrapped result while preserving the original result. /// @@ -554,6 +655,27 @@ Func>> bindAsync return await resultTask.ConfigureAwait(false); } + private static FailOr ThenEnsureCore( + TSource value, + Func> ensure + ) + { + var ensured = ensure(value); + return ensured.IsFailure ? FailOr.Fail(ensured.Failures) : FailOr.Success(value); + } + + private static async Task> ThenEnsureAsyncCore( + TSource value, + Func>> ensureAsync + ) + { + var resultTask = ensureAsync(value); + ArgumentNullException.ThrowIfNull(resultTask); + + var ensured = await resultTask.ConfigureAwait(false); + return ensured.IsFailure ? FailOr.Fail(ensured.Failures) : FailOr.Success(value); + } + private static async Task> ThenDoAsyncCore( TSource value, Func actionAsync diff --git a/tests/FailOr.Tests/FailOrThenTests.cs b/tests/FailOr.Tests/FailOrThenTests.cs index 2294158..014689e 100644 --- a/tests/FailOr.Tests/FailOrThenTests.cs +++ b/tests/FailOr.Tests/FailOrThenTests.cs @@ -26,6 +26,41 @@ public async Task Then_bind_returns_inner_result(int value, int expected) await ThenAssertions.AssertSuccess(result, expected); } + [Test] + [Arguments(1)] + [Arguments(5)] + public async Task ThenEnsure_preserves_successful_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = source.ThenEnsure(x => + { + observed = x; + calls++; + return FailOr.Success(x * 2); + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + + [Test] + public async Task ThenEnsure_returns_ensure_failures() + { + var firstFailure = Failure.General("validation failed"); + var secondFailure = Failure.Validation("Value", "Out of range"); + + var result = FailOr + .Success(5) + .ThenEnsure(_ => FailOr.Fail(firstFailure, secondFailure)); + + await ThenAssertions.AssertFailure(result, firstFailure, secondFailure); + } + [Test] [Arguments(1, 4)] [Arguments(5, 8)] @@ -48,6 +83,41 @@ public async Task ThenAsync_bind_returns_inner_result(int value, int expected) await ThenAssertions.AssertSuccess(result, expected); } + [Test] + [Arguments(1)] + [Arguments(5)] + public async Task ThenEnsureAsync_preserves_successful_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = await source.ThenEnsureAsync(x => + { + observed = x; + calls++; + return Task.FromResult(FailOr.Success(x * 2)); + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + + [Test] + public async Task ThenEnsureAsync_returns_ensure_failures() + { + var firstFailure = Failure.General("async validation failed"); + var secondFailure = Failure.Validation("Value", "Still out of range"); + + var result = await FailOr + .Success(5) + .ThenEnsureAsync(_ => Task.FromResult(FailOr.Fail(firstFailure, secondFailure))); + + await ThenAssertions.AssertFailure(result, firstFailure, secondFailure); + } + [Test] [Arguments(1)] [Arguments(5)] @@ -152,6 +222,31 @@ public async Task ThenDo_propagates_delegate_exceptions() } } + [Test] + public async Task ThenEnsure_propagates_delegate_exceptions() + { + var expected = new InvalidOperationException("ThenEnsure failed"); + + try + { + _ = FailOr + .Success(1) + .ThenEnsure( + (Func>)( + _ => + { + throw expected; + } + ) + ); + throw new Exception("Expected ThenEnsure to rethrow the original exception."); + } + catch (InvalidOperationException actual) + { + await Assert.That(ReferenceEquals(actual, expected)).IsTrue(); + } + } + [Test] public async Task ThenDoAsync_propagates_delegate_exceptions() { @@ -168,6 +263,26 @@ public async Task ThenDoAsync_propagates_delegate_exceptions() } } + [Test] + public async Task ThenEnsureAsync_propagates_delegate_exceptions() + { + var expected = new InvalidOperationException("ThenEnsureAsync failed"); + + try + { + _ = await FailOr + .Success(1) + .ThenEnsureAsync( + (Func>>)(_ => Task.FromException>(expected)) + ); + throw new Exception("Expected ThenEnsureAsync to rethrow the original exception."); + } + catch (InvalidOperationException actual) + { + await Assert.That(ReferenceEquals(actual, expected)).IsTrue(); + } + } + [Test] [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.DirectIfFailThenSuccessCases))] public async Task IfFailThen_preserves_success_for_all_direct_overloads( diff --git a/tests/FailOr.Tests/TaskFailOrThenTests.cs b/tests/FailOr.Tests/TaskFailOrThenTests.cs index ee2ffce..5294f04 100644 --- a/tests/FailOr.Tests/TaskFailOrThenTests.cs +++ b/tests/FailOr.Tests/TaskFailOrThenTests.cs @@ -26,6 +26,41 @@ public async Task Then_bind_returns_inner_task_result(int value, int expected) await ThenAssertions.AssertSuccess(result, expected); } + [Test] + [Arguments(1)] + [Arguments(5)] + public async Task ThenEnsure_preserves_successful_task_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = await Task.FromResult(source) + .ThenEnsure(x => + { + observed = x; + calls++; + return FailOr.Success(x * 2); + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + + [Test] + public async Task ThenEnsure_returns_inner_task_failures() + { + var firstFailure = Failure.General("validation failed"); + var secondFailure = Failure.Validation("Value", "Out of range"); + + var result = await Task.FromResult(FailOr.Success(5)) + .ThenEnsure(_ => FailOr.Fail(firstFailure, secondFailure)); + + await ThenAssertions.AssertFailure(result, firstFailure, secondFailure); + } + [Test] [Arguments(1, 4)] [Arguments(5, 8)] @@ -48,6 +83,41 @@ public async Task ThenAsync_bind_returns_inner_task_result(int value, int expect await ThenAssertions.AssertSuccess(result, expected); } + [Test] + [Arguments(1)] + [Arguments(5)] + public async Task ThenEnsureAsync_preserves_successful_task_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = await Task.FromResult(source) + .ThenEnsureAsync(x => + { + observed = x; + calls++; + return Task.FromResult(FailOr.Success(x * 2)); + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + + [Test] + public async Task ThenEnsureAsync_returns_inner_task_failures() + { + var firstFailure = Failure.General("async validation failed"); + var secondFailure = Failure.Validation("Value", "Still out of range"); + + var result = await Task.FromResult(FailOr.Success(5)) + .ThenEnsureAsync(_ => Task.FromResult(FailOr.Fail(firstFailure, secondFailure))); + + await ThenAssertions.AssertFailure(result, firstFailure, secondFailure); + } + [Test] [Arguments(1)] [Arguments(5)] @@ -164,6 +234,30 @@ public async Task ThenDo_propagates_delegate_exceptions_for_task_wrapped_sources } } + [Test] + public async Task ThenEnsure_propagates_delegate_exceptions_for_task_wrapped_sources() + { + var expected = new InvalidOperationException("ThenEnsure task failed"); + + try + { + _ = await Task.FromResult(FailOr.Success(1)) + .ThenEnsure( + (Func>)( + _ => + { + throw expected; + } + ) + ); + throw new Exception("Expected ThenEnsure to rethrow the original exception."); + } + catch (InvalidOperationException actual) + { + await Assert.That(ReferenceEquals(actual, expected)).IsTrue(); + } + } + [Test] public async Task ThenDoAsync_propagates_delegate_exceptions_for_task_wrapped_sources() { @@ -181,6 +275,25 @@ public async Task ThenDoAsync_propagates_delegate_exceptions_for_task_wrapped_so } } + [Test] + public async Task ThenEnsureAsync_propagates_delegate_exceptions_for_task_wrapped_sources() + { + var expected = new InvalidOperationException("ThenEnsureAsync task failed"); + + try + { + _ = await Task.FromResult(FailOr.Success(1)) + .ThenEnsureAsync( + (Func>>)(_ => Task.FromException>(expected)) + ); + throw new Exception("Expected ThenEnsureAsync to rethrow the original exception."); + } + catch (InvalidOperationException actual) + { + await Assert.That(ReferenceEquals(actual, expected)).IsTrue(); + } + } + [Test] [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.LiftedParityCases))] public async Task Lifted_overloads_match_direct_behavior( diff --git a/tests/FailOr.Tests/ThenTestData.cs b/tests/FailOr.Tests/ThenTestData.cs index 2c993cc..235657a 100644 --- a/tests/FailOr.Tests/ThenTestData.cs +++ b/tests/FailOr.Tests/ThenTestData.cs @@ -17,6 +17,12 @@ public static IEnumerable< (source, counter) => Task.FromResult(source.Then(x => FailOr.Success(x + counter.Increment()))) ); + yield return () => + ( + "ThenEnsure", + (source, counter) => + Task.FromResult(source.ThenEnsure(x => FailOr.Success(x + counter.Increment()))) + ); yield return () => ( "ThenDo", @@ -39,6 +45,14 @@ public static IEnumerable< (source, counter) => source.ThenAsync(x => Task.FromResult(FailOr.Success(x + counter.Increment()))) ); + yield return () => + ( + "ThenEnsureAsync", + (source, counter) => + source.ThenEnsureAsync(x => + Task.FromResult(FailOr.Success(x + counter.Increment())) + ) + ); yield return () => ( "ThenDoAsync", @@ -58,6 +72,12 @@ public static IEnumerable< yield return () => ("Then map", () => FailOr.Success(1).Then((Func)null!), "map"); yield return () => ("Then bind", () => FailOr.Success(1).Then((Func>)null!), "bind"); + yield return () => + ( + "ThenEnsure", + () => FailOr.Success(1).ThenEnsure((Func>)null!), + "ensure" + ); yield return () => ("ThenDo", () => FailOr.Success(1).ThenDo((Action)null!), "action"); yield return () => ( @@ -71,6 +91,12 @@ public static IEnumerable< () => FailOr.Success(1).ThenAsync((Func>>)null!), "bindAsync" ); + yield return () => + ( + "ThenEnsureAsync", + () => FailOr.Success(1).ThenEnsureAsync((Func>>)null!), + "ensureAsync" + ); yield return () => ( "ThenDoAsync", @@ -85,6 +111,12 @@ public static IEnumerable< { yield return () => ("ThenDoAsync", () => FailOr.Success(1).ThenDoAsync(_ => null!), "resultTask"); + yield return () => + ( + "ThenEnsureAsync", + () => FailOr.Success(1).ThenEnsureAsync((Func>>)(_ => null!)), + "resultTask" + ); } public static IEnumerable< @@ -102,6 +134,12 @@ Func>, InvocationCounter, Task>> Invoke (sourceTask, counter) => sourceTask.Then(x => FailOr.Success(x + counter.Increment())) ); + yield return () => + ( + "ThenEnsure", + (sourceTask, counter) => + sourceTask.ThenEnsure(x => FailOr.Success(x + counter.Increment())) + ); yield return () => ( "ThenDo", @@ -125,6 +163,14 @@ Func>, InvocationCounter, Task>> Invoke Task.FromResult(FailOr.Success(x + counter.Increment())) ) ); + yield return () => + ( + "ThenEnsureAsync", + (sourceTask, counter) => + sourceTask.ThenEnsureAsync(x => + Task.FromResult(FailOr.Success(x + counter.Increment())) + ) + ); yield return () => ( "ThenDoAsync", @@ -153,6 +199,12 @@ public static IEnumerable< () => Task.FromResult(FailOr.Success(1)).Then((Func>)null!), "bind" ); + yield return () => + ( + "ThenEnsure", + () => Task.FromResult(FailOr.Success(1)).ThenEnsure((Func>)null!), + "ensure" + ); yield return () => ( "ThenDo", @@ -173,6 +225,14 @@ public static IEnumerable< .ThenAsync((Func>>)null!), "bindAsync" ); + yield return () => + ( + "ThenEnsureAsync", + () => + Task.FromResult(FailOr.Success(1)) + .ThenEnsureAsync((Func>>)null!), + "ensureAsync" + ); yield return () => ( "ThenDoAsync", @@ -187,12 +247,27 @@ public static IEnumerable< { yield return () => ("ThenDo", () => ((Task>)null!).ThenDo(_ => { }), "sourceTask"); + yield return () => + ( + "ThenEnsure", + () => ((Task>)null!).ThenEnsure(_ => FailOr.Success(1)), + "sourceTask" + ); yield return () => ( "ThenDoAsync", () => ((Task>)null!).ThenDoAsync(_ => Task.CompletedTask), "sourceTask" ); + yield return () => + ( + "ThenEnsureAsync", + () => + ((Task>)null!).ThenEnsureAsync(_ => + Task.FromResult(FailOr.Success(1)) + ), + "sourceTask" + ); } public static IEnumerable< @@ -205,6 +280,14 @@ public static IEnumerable< () => Task.FromResult(FailOr.Success(1)).ThenDoAsync(_ => null!), "resultTask" ); + yield return () => + ( + "ThenEnsureAsync", + () => + Task.FromResult(FailOr.Success(1)) + .ThenEnsureAsync((Func>>)(_ => null!)), + "resultTask" + ); } public static IEnumerable< @@ -230,6 +313,13 @@ int Expected sourceTask => sourceTask.Then(x => FailOr.Success(x + 1)), 2 ); + yield return () => + ( + "ThenEnsure", + source => Task.FromResult(source.ThenEnsure(x => FailOr.Success(x + 1))), + sourceTask => sourceTask.ThenEnsure(x => FailOr.Success(x + 1)), + 1 + ); yield return () => ( "ThenDo", @@ -251,6 +341,14 @@ int Expected sourceTask => sourceTask.ThenAsync(x => Task.FromResult(FailOr.Success(x + 1))), 2 ); + yield return () => + ( + "ThenEnsureAsync", + source => source.ThenEnsureAsync(x => Task.FromResult(FailOr.Success(x + 1))), + sourceTask => + sourceTask.ThenEnsureAsync(x => Task.FromResult(FailOr.Success(x + 1))), + 1 + ); yield return () => ( "ThenDoAsync",