diff --git a/README.md b/README.md index 9410e12..6810a30 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(...)` and `ThenAsync(...)` for chaining +- `Then(...)`, `ThenAsync(...)`, `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 @@ -129,6 +129,34 @@ var result = await FailOr.Success(10) }); ``` +### Run success-side effects with `ThenDo` + +Use `ThenDo` when you want to observe a success without changing the flowing result. + +```csharp +using FailOr; + +var result = FailOr.Success(10) + .ThenDo(value => Console.WriteLine($"Observed: {value}")) + .Then(value => value + 5); + +var finalValue = result.UnsafeUnwrap(); // 15 +``` + +The same side-effect helpers are available for task-wrapped results: + +```csharp +using FailOr; + +var result = await Task.FromResult(FailOr.Success(10)) + .ThenDoAsync(async value => + { + await Task.Delay(10); + Console.WriteLine($"Observed: {value}"); + }) + .Then(value => value + 5); +``` + ### Branch with `Match` Use `Match` when you want a single expression that handles both outcomes. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9bf2794..05e6591 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -299,6 +299,38 @@ var result = await FailOr.Success("42") .ThenAsync(ParseNumberAsync); ``` +### Run a side effect and preserve the success + +```csharp +public FailOr ThenDo(Action action) +``` + +Intent: +Run a side effect such as logging, metrics, or caching without changing the success value. + +Example: + +```csharp +var result = FailOr.Success(10) + .ThenDo(x => Console.WriteLine($"Observed {x}")); +``` + +### Run a side effect asynchronously and preserve the success + +```csharp +public Task> ThenDoAsync(Func actionAsync) +``` + +Intent: +Run an asynchronous side effect without changing the success value. + +Example: + +```csharp +var result = await FailOr.Success(10) + .ThenDoAsync(async x => await AuditAsync(x)); +``` + ## Recovering From Failures These APIs preserve an existing success. They only apply the fallback when the source is failed. @@ -519,7 +551,7 @@ var result = FailOr.Zip( ## Task-Wrapped Result Extensions -The library also provides the same `Then`, `ThenAsync`, `IfFailThen`, `IfFailThenAsync`, `Match`, `MatchAsync`, `MatchFirst`, and `MatchFirstAsync` APIs for: +The library also provides the same `Then`, `ThenAsync`, `ThenDo`, `ThenDoAsync`, `IfFailThen`, `IfFailThenAsync`, `Match`, `MatchAsync`, `MatchFirst`, and `MatchFirstAsync` APIs for: ```csharp Task> @@ -538,6 +570,25 @@ var message = await GetUserAsync() failure: failures => failures[0].Details); ``` +### Preserve a task-wrapped success while running side effects + +```csharp +public Task> ThenDo(Action action) +public Task> ThenDoAsync(Func actionAsync) +``` + +Intent: +Observe or audit the successful value from a `Task>` without changing the flowing result. + +Example: + +```csharp +var result = await GetUserAsync() + .ThenDo(user => Console.WriteLine(user.Email)) + .ThenDoAsync(user => AuditAsync(user)) + .Then(user => user.Email); +``` + ## Validation Rules And Exceptions The current API validates a few important invalid states: @@ -556,6 +607,7 @@ The current API validates a few important invalid states: - Use `Success` and `Fail` to create results. - Use `Then` and `ThenAsync` to continue only on success. +- Use `ThenDo` and `ThenDoAsync` to run side effects while preserving the success value. - Use `IfFailThen` and `IfFailThenAsync` to recover from failures. - Use `Match` when you want a final value from both branches. - Use `MatchFirst` when only the first failure matters. diff --git a/src/FailOr/FailOrT.Then.cs b/src/FailOr/FailOrT.Then.cs index 80508df..ccc210e 100644 --- a/src/FailOr/FailOrT.Then.cs +++ b/src/FailOr/FailOrT.Then.cs @@ -103,6 +103,58 @@ Func>> bindAsync : ThenBindAsync(source.UnsafeUnwrap(), bindAsync); } + /// + /// Runs a side effect for a successful value while preserving the original result. + /// + /// The side effect to run when the source is successful. + /// + /// The original success unchanged, or the original failures when the source is failed. + /// + /// + /// Thrown when is . + /// + /// + /// + /// var next = result.ThenDo(x => Console.WriteLine(x)); + /// + /// + public FailOr ThenDo(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (source.IsFailure) + { + return source; + } + + action(source.UnsafeUnwrap()); + return source; + } + + /// + /// Asynchronously runs a side effect for a successful value while preserving the original result. + /// + /// The asynchronous side effect to run when the source is successful. + /// + /// A task producing the original success unchanged, or the original failures when the source is failed. + /// + /// + /// Thrown when is or returns . + /// + /// + /// + /// var next = await result.ThenDoAsync(x => WriteAsync(x)); + /// + /// + public Task> ThenDoAsync(Func actionAsync) + { + ArgumentNullException.ThrowIfNull(actionAsync); + + return source.IsFailure + ? Task.FromResult(source) + : ThenDoAsyncCore(source.UnsafeUnwrap(), actionAsync); + } + /// /// Returns the current success unchanged, or the provided alternative result when the source is failed. /// @@ -311,6 +363,53 @@ Func>> bindAsync return ThenCore(sourceTask, source => source.ThenAsync(bindAsync)); } + /// + /// Runs a side effect for the successful value of a task-wrapped result while preserving the original result. + /// + /// The side effect to run when the awaited source is successful. + /// + /// A task producing the original success unchanged, or the original failures when the awaited source is failed. + /// + /// + /// Thrown when the awaited source task or is . + /// + /// + /// + /// var next = await resultTask.ThenDo(x => Console.WriteLine(x)); + /// + /// + public Task> ThenDo(Action action) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(action); + + return ThenCore(sourceTask, source => Task.FromResult(source.ThenDo(action))); + } + + /// + /// Asynchronously runs a side effect for the successful value of a task-wrapped result while preserving the original result. + /// + /// The asynchronous side effect to run when the awaited source is successful. + /// + /// A task producing the original success unchanged, 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.ThenDoAsync(x => WriteAsync(x)); + /// + /// + public Task> ThenDoAsync(Func actionAsync) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(actionAsync); + + return ThenCore(sourceTask, source => source.ThenDoAsync(actionAsync)); + } + /// /// Returns the awaited success unchanged, or the provided alternative result when the awaited source is failed. /// @@ -455,6 +554,18 @@ Func>> bindAsync return await resultTask.ConfigureAwait(false); } + private static async Task> ThenDoAsyncCore( + TSource value, + Func actionAsync + ) + { + var resultTask = actionAsync(value); + ArgumentNullException.ThrowIfNull(resultTask); + + await resultTask.ConfigureAwait(false); + return FailOr.Success(value); + } + private static async Task> IfFailThenAsyncCore( Func>> alternativeAsync ) diff --git a/tests/FailOr.Tests/FailOrThenTests.cs b/tests/FailOr.Tests/FailOrThenTests.cs index 33a6e3f..2294158 100644 --- a/tests/FailOr.Tests/FailOrThenTests.cs +++ b/tests/FailOr.Tests/FailOrThenTests.cs @@ -48,6 +48,49 @@ 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 ThenDo_preserves_successful_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = source.ThenDo(x => + { + observed = x; + calls++; + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + + [Test] + [Arguments(1)] + [Arguments(5)] + public async Task ThenDoAsync_preserves_successful_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = await source.ThenDoAsync(x => + { + observed = x; + calls++; + return Task.CompletedTask; + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + [Test] [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.DirectFailureShortCircuitCases))] public async Task Failed_sources_short_circuit_all_direct_overloads( @@ -77,6 +120,54 @@ string parameterName await Assert.That(invoke).Throws().WithParameterName(parameterName); } + [Test] + [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.DirectNullTaskCases))] + public async Task Null_tasks_throw_for_direct_async_ThenDo_overloads( + string operation, + Func invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + public async Task ThenDo_propagates_delegate_exceptions() + { + var expected = new InvalidOperationException("ThenDo failed"); + + try + { + _ = FailOr + .Success(1) + .ThenDo(_ => + { + throw expected; + }); + throw new Exception("Expected ThenDo to rethrow the original exception."); + } + catch (InvalidOperationException actual) + { + await Assert.That(ReferenceEquals(actual, expected)).IsTrue(); + } + } + + [Test] + public async Task ThenDoAsync_propagates_delegate_exceptions() + { + var expected = new InvalidOperationException("ThenDoAsync failed"); + + try + { + _ = await FailOr.Success(1).ThenDoAsync(_ => Task.FromException(expected)); + throw new Exception("Expected ThenDoAsync 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 b1707fb..ee2ffce 100644 --- a/tests/FailOr.Tests/TaskFailOrThenTests.cs +++ b/tests/FailOr.Tests/TaskFailOrThenTests.cs @@ -48,6 +48,51 @@ 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 ThenDo_preserves_successful_task_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = await Task.FromResult(source) + .ThenDo(x => + { + observed = x; + calls++; + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + + [Test] + [Arguments(1)] + [Arguments(5)] + public async Task ThenDoAsync_preserves_successful_task_values(int value) + { + var source = FailOr.Success(value); + var calls = 0; + var observed = 0; + + var result = await Task.FromResult(source) + .ThenDoAsync(x => + { + observed = x; + calls++; + return Task.CompletedTask; + }); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(1); + await Assert.That(observed).IsEqualTo(value); + await ThenAssertions.AssertEquivalent(result, source); + } + [Test] [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.LiftedFailureShortCircuitCases))] public async Task Failed_sources_short_circuit_all_lifted_overloads( @@ -77,6 +122,65 @@ string parameterName await Assert.That(invoke).Throws().WithParameterName(parameterName); } + [Test] + [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.LiftedNullSourceCases))] + public async Task Null_source_tasks_throw_for_lifted_ThenDo_overloads( + string operation, + Action invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + [MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.LiftedNullTaskCases))] + public async Task Null_tasks_throw_for_lifted_async_ThenDo_overloads( + string operation, + Func invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + public async Task ThenDo_propagates_delegate_exceptions_for_task_wrapped_sources() + { + var expected = new InvalidOperationException("ThenDo task failed"); + + try + { + _ = await Task.FromResult(FailOr.Success(1)) + .ThenDo(_ => + { + throw expected; + }); + throw new Exception("Expected ThenDo 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() + { + var expected = new InvalidOperationException("ThenDoAsync task failed"); + + try + { + _ = await Task.FromResult(FailOr.Success(1)) + .ThenDoAsync(_ => Task.FromException(expected)); + throw new Exception("Expected ThenDoAsync 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 7288ba9..2c993cc 100644 --- a/tests/FailOr.Tests/ThenTestData.cs +++ b/tests/FailOr.Tests/ThenTestData.cs @@ -17,6 +17,17 @@ public static IEnumerable< (source, counter) => Task.FromResult(source.Then(x => FailOr.Success(x + counter.Increment()))) ); + yield return () => + ( + "ThenDo", + (source, counter) => + Task.FromResult( + source.ThenDo(_ => + { + counter.Increment(); + }) + ) + ); yield return () => ( "ThenAsync map", @@ -28,6 +39,16 @@ public static IEnumerable< (source, counter) => source.ThenAsync(x => Task.FromResult(FailOr.Success(x + counter.Increment()))) ); + yield return () => + ( + "ThenDoAsync", + (source, counter) => + source.ThenDoAsync(_ => + { + counter.Increment(); + return Task.CompletedTask; + }) + ); } public static IEnumerable< @@ -37,6 +58,7 @@ 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 () => ("ThenDo", () => FailOr.Success(1).ThenDo((Action)null!), "action"); yield return () => ( "ThenAsync map", @@ -49,6 +71,20 @@ public static IEnumerable< () => FailOr.Success(1).ThenAsync((Func>>)null!), "bindAsync" ); + yield return () => + ( + "ThenDoAsync", + () => FailOr.Success(1).ThenDoAsync((Func)null!), + "actionAsync" + ); + } + + public static IEnumerable< + Func<(string Operation, Func Invoke, string ParameterName)> + > DirectNullTaskCases() + { + yield return () => + ("ThenDoAsync", () => FailOr.Success(1).ThenDoAsync(_ => null!), "resultTask"); } public static IEnumerable< @@ -66,6 +102,15 @@ Func>, InvocationCounter, Task>> Invoke (sourceTask, counter) => sourceTask.Then(x => FailOr.Success(x + counter.Increment())) ); + yield return () => + ( + "ThenDo", + (sourceTask, counter) => + sourceTask.ThenDo(_ => + { + counter.Increment(); + }) + ); yield return () => ( "ThenAsync map", @@ -80,6 +125,16 @@ Func>, InvocationCounter, Task>> Invoke Task.FromResult(FailOr.Success(x + counter.Increment())) ) ); + yield return () => + ( + "ThenDoAsync", + (sourceTask, counter) => + sourceTask.ThenDoAsync(_ => + { + counter.Increment(); + return Task.CompletedTask; + }) + ); } public static IEnumerable< @@ -98,6 +153,12 @@ public static IEnumerable< () => Task.FromResult(FailOr.Success(1)).Then((Func>)null!), "bind" ); + yield return () => + ( + "ThenDo", + () => Task.FromResult(FailOr.Success(1)).ThenDo((Action)null!), + "action" + ); yield return () => ( "ThenAsync map", @@ -112,6 +173,38 @@ public static IEnumerable< .ThenAsync((Func>>)null!), "bindAsync" ); + yield return () => + ( + "ThenDoAsync", + () => Task.FromResult(FailOr.Success(1)).ThenDoAsync((Func)null!), + "actionAsync" + ); + } + + public static IEnumerable< + Func<(string Operation, Action Invoke, string ParameterName)> + > LiftedNullSourceCases() + { + yield return () => + ("ThenDo", () => ((Task>)null!).ThenDo(_ => { }), "sourceTask"); + yield return () => + ( + "ThenDoAsync", + () => ((Task>)null!).ThenDoAsync(_ => Task.CompletedTask), + "sourceTask" + ); + } + + public static IEnumerable< + Func<(string Operation, Func Invoke, string ParameterName)> + > LiftedNullTaskCases() + { + yield return () => + ( + "ThenDoAsync", + () => Task.FromResult(FailOr.Success(1)).ThenDoAsync(_ => null!), + "resultTask" + ); } public static IEnumerable< @@ -137,6 +230,13 @@ int Expected sourceTask => sourceTask.Then(x => FailOr.Success(x + 1)), 2 ); + yield return () => + ( + "ThenDo", + source => Task.FromResult(source.ThenDo(_ => { })), + sourceTask => sourceTask.ThenDo(_ => { }), + 1 + ); yield return () => ( "ThenAsync map", @@ -151,6 +251,13 @@ int Expected sourceTask => sourceTask.ThenAsync(x => Task.FromResult(FailOr.Success(x + 1))), 2 ); + yield return () => + ( + "ThenDoAsync", + source => source.ThenDoAsync(_ => Task.CompletedTask), + sourceTask => sourceTask.ThenDoAsync(_ => Task.CompletedTask), + 1 + ); } public static IEnumerable<