From 1c9ac9bf36dafee9516eefd420d88d95916a7aea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:39:17 +0000 Subject: [PATCH 1/7] Initial plan From 3d0f11f8044621aa1772bbfc0162e15622440ddb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:45:04 +0000 Subject: [PATCH 2/7] Add ThenEnsure helpers and coverage Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- README.md | 34 +++++- docs/api-reference.md | 41 ++++++++ src/FailOr/FailOrT.Then.cs | 122 ++++++++++++++++++++++ tests/FailOr.Tests/FailOrThenTests.cs | 115 ++++++++++++++++++++ tests/FailOr.Tests/TaskFailOrThenTests.cs | 113 ++++++++++++++++++++ tests/FailOr.Tests/ThenTestData.cs | 98 +++++++++++++++++ 6 files changed, 522 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6810a30..e596d18 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 @@ -129,6 +129,38 @@ var result = await FailOr.Success(10) }); ``` +### 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 05e6591..73a549f 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 @@ -299,6 +318,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", From 8b5230a966404ecbfe30cc9dd2e28ad31c82b256 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:01:48 +0000 Subject: [PATCH 3/7] Resolve README merge conflict Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index e596d18..2dd0556 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,34 @@ var result = await FailOr.Success(10) }); ``` +### Map safely with `Try` + +Use `Try` when the next step should only run after success, but thrown exceptions should become failures instead of escaping the pipeline. + +```csharp +using FailOr; + +var result = FailOr.Success("42") + .Try(value => int.Parse(value)); + +if (result.IsFailure) +{ + var failure = (Failures.Exceptional)result.Failures[0]; + Console.WriteLine(failure.Exception.Message); +} +``` + +You can also translate the exception into a custom repo-native result: + +```csharp +using FailOr; + +var result = FailOr.Success("42x") + .Try( + value => int.Parse(value), + 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. From 73dd0d90b76cb9f9dfbfa38c1243658d3979298b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:02:21 +0000 Subject: [PATCH 4/7] Tidy README conflict wording Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2dd0556..ee3f945 100644 --- a/README.md +++ b/README.md @@ -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; From 635e1199c42b0ced431fac0a2979533455515f1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:25:08 +0000 Subject: [PATCH 5/7] Merge main and resolve README conflict Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- README.md | 3 +- docs/api-reference.md | 58 +++- src/FailOr/FailOrT.Try.cs | 304 ++++++++++++++++++++ tests/FailOr.Tests/FailOrTryTests.cs | 201 +++++++++++++ tests/FailOr.Tests/TaskFailOrTryTests.cs | 70 +++++ tests/FailOr.Tests/TryTestData.cs | 343 +++++++++++++++++++++++ 6 files changed, 976 insertions(+), 3 deletions(-) create mode 100644 src/FailOr/FailOrT.Try.cs create mode 100644 tests/FailOr.Tests/FailOrTryTests.cs create mode 100644 tests/FailOr.Tests/TaskFailOrTryTests.cs create mode 100644 tests/FailOr.Tests/TryTestData.cs diff --git a/README.md b/README.md index ee3f945..d2d752e 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ if (result.IsFailure) } ``` -You can also translate the exception into a custom repository-native result: +You can also translate the exception into a custom repo-native result: ```csharp using FailOr; @@ -188,7 +188,6 @@ var result = await FailOr.Success(10) : 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 73a549f..393b18a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -302,6 +302,61 @@ var result = await FailOr.Success(10) .ThenAsync(async x => await GetAdjustedValueAsync(x)); ``` +### Map a success value with exception handling + +```csharp +public FailOr Try(Func map) +``` + +Intent: +Transform a success value while converting thrown exceptions to `Failure.Exceptional(...)`. + +Example: + +```csharp +var result = FailOr.Success("42") + .Try(int.Parse); +``` + +### Map a success value with custom exception handling + +```csharp +public FailOr Try( + Func map, + Func> onException) +``` + +Intent: +Transform a success value while projecting thrown exceptions into a custom `FailOr`. + +Example: + +```csharp +var result = FailOr.Success("42x") + .Try( + int.Parse, + exception => Failure.General($"Mapping failed: {exception.Message}")); +``` + +### Map asynchronously with exception handling + +```csharp +public Task> TryAsync(Func> mapAsync) +public Task> TryAsync( + Func> mapAsync, + Func> onException) +``` + +Intent: +Transform a success value with an asynchronous operation while converting thrown exceptions to either `Failure.Exceptional(...)` or a custom projected result. + +Example: + +```csharp +var result = await FailOr.Success("42") + .TryAsync(value => ParseNumberAsync(value)); +``` + ### Bind asynchronously ```csharp @@ -592,7 +647,7 @@ var result = FailOr.Zip( ## Task-Wrapped Result Extensions -The library also provides the same `Then`, `ThenAsync`, `ThenDo`, `ThenDoAsync`, `IfFailThen`, `IfFailThenAsync`, `Match`, `MatchAsync`, `MatchFirst`, and `MatchFirstAsync` APIs for: +The library also provides the same `Then`, `ThenAsync`, `Try`, `TryAsync`, `ThenDo`, `ThenDoAsync`, `IfFailThen`, `IfFailThenAsync`, `Match`, `MatchAsync`, `MatchFirst`, and `MatchFirstAsync` APIs for: ```csharp Task> @@ -643,6 +698,7 @@ The current API validates a few important invalid states: - `UnsafeUnwrap` throws `InvalidOperationException` when the result is failed. - Async delegate-based APIs throw `ArgumentNullException` when the delegate itself is `null`. - Async delegate-based APIs also throw `ArgumentNullException` when the selected delegate returns a `null` task. +- `Try` and `TryAsync` convert exceptions thrown by mapping delegates into `Failure.Exceptional(...)` unless you provide a custom exception projection. ## Choosing The Right API diff --git a/src/FailOr/FailOrT.Try.cs b/src/FailOr/FailOrT.Try.cs new file mode 100644 index 0000000..d81fa2d --- /dev/null +++ b/src/FailOr/FailOrT.Try.cs @@ -0,0 +1,304 @@ +namespace FailOr; + +/// +/// Provides exception-safe mapping extensions for values. +/// +public static class FailOrTryExtensions +{ + extension(FailOr source) + { + /// + /// Maps a successful value to a new successful result and converts thrown exceptions to an exceptional failure. + /// + /// The projection to apply when the source is successful. + /// + /// A successful result containing the mapped value, the original failures when the source is failed, + /// or a failed result containing when + /// the mapping throws. + /// + /// + /// Thrown when is . + /// + /// + /// + /// var next = result.Try(x => x + 1); + /// + /// + public FailOr Try(Func map) + { + ArgumentNullException.ThrowIfNull(map); + + return source.IsFailure + ? Fail(source) + : TryCore(source.UnsafeUnwrap(), map, Exceptional); + } + + /// + /// Maps a successful value to a new result and converts thrown exceptions with a custom handler. + /// + /// The projection to apply when the source is successful. + /// The handler that converts a thrown exception into a result. + /// + /// A successful result containing the mapped value, the original failures when the source is failed, + /// or the result produced by when the mapping throws. + /// + /// + /// Thrown when or is . + /// + /// + /// + /// var next = result.Try( + /// x => x + 1, + /// exception => Failure.General("Mapping failed.")); + /// + /// + public FailOr Try( + Func map, + Func> onException + ) + { + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(onException); + + return source.IsFailure + ? Fail(source) + : TryCore(source.UnsafeUnwrap(), map, onException); + } + + /// + /// Asynchronously maps a successful value to a new result and converts thrown exceptions to an exceptional failure. + /// + /// The asynchronous projection to apply when the source is successful. + /// + /// A task producing a successful result containing the mapped value, the original failures when the + /// source is failed, or a failed result containing + /// when the mapping throws. + /// + /// + /// Thrown when is or returns . + /// + /// + /// + /// var next = await result.TryAsync(x => GetValueAsync(x)); + /// + /// + public Task> TryAsync(Func> mapAsync) + { + ArgumentNullException.ThrowIfNull(mapAsync); + + return source.IsFailure + ? Task.FromResult(Fail(source)) + : TryMapAsync(source.UnsafeUnwrap(), mapAsync, Exceptional); + } + + /// + /// Asynchronously maps a successful value to a new result and converts thrown exceptions with a custom handler. + /// + /// The asynchronous projection to apply when the source is successful. + /// The handler that converts a thrown exception into a result. + /// + /// A task producing a successful result containing the mapped value, the original failures when the + /// source is failed, or the result produced by when the mapping throws. + /// + /// + /// Thrown when or is , + /// or when returns . + /// + /// + /// + /// var next = await result.TryAsync( + /// x => GetValueAsync(x), + /// exception => Failure.General("Mapping failed.")); + /// + /// + public Task> TryAsync( + Func> mapAsync, + Func> onException + ) + { + ArgumentNullException.ThrowIfNull(mapAsync); + ArgumentNullException.ThrowIfNull(onException); + + return source.IsFailure + ? Task.FromResult(Fail(source)) + : TryMapAsync(source.UnsafeUnwrap(), mapAsync, onException); + } + } + + extension(Task> sourceTask) + { + /// + /// Maps the successful value of a task-wrapped result and converts thrown exceptions to an exceptional failure. + /// + /// The projection to apply when the awaited source is successful. + /// + /// A task producing a successful result containing the mapped value, the original failures when the + /// awaited source is failed, or a failed result containing + /// when the mapping throws. + /// + /// + /// Thrown when the awaited source task or is . + /// + /// + /// + /// var next = await resultTask.Try(x => x + 1); + /// + /// + public Task> Try(Func map) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(map); + + return TryCore(sourceTask, source => Task.FromResult(source.Try(map))); + } + + /// + /// Maps the successful value of a task-wrapped result and converts thrown exceptions with a custom handler. + /// + /// The projection to apply when the awaited source is successful. + /// The handler that converts a thrown exception into a result. + /// + /// A task producing a successful result containing the mapped value, the original failures when the + /// awaited source is failed, or the result produced by when the mapping throws. + /// + /// + /// Thrown when the awaited source task, , or is . + /// + /// + /// + /// var next = await resultTask.Try( + /// x => x + 1, + /// exception => Failure.General("Mapping failed.")); + /// + /// + public Task> Try( + Func map, + Func> onException + ) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(onException); + + return TryCore(sourceTask, source => Task.FromResult(source.Try(map, onException))); + } + + /// + /// Asynchronously maps the successful value of a task-wrapped result and converts thrown exceptions to an exceptional failure. + /// + /// The asynchronous projection to apply when the awaited source is successful. + /// + /// A task producing a successful result containing the mapped value, the original failures when the + /// awaited source is failed, or a failed result containing + /// when the mapping throws. + /// + /// + /// Thrown when the awaited source task or is , + /// or when returns . + /// + /// + /// + /// var next = await resultTask.TryAsync(x => GetValueAsync(x)); + /// + /// + public Task> TryAsync(Func> mapAsync) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(mapAsync); + + return TryCore(sourceTask, source => source.TryAsync(mapAsync)); + } + + /// + /// Asynchronously maps the successful value of a task-wrapped result and converts thrown exceptions with a custom handler. + /// + /// The asynchronous projection to apply when the awaited source is successful. + /// The handler that converts a thrown exception into a result. + /// + /// A task producing a successful result containing the mapped value, the original failures when the + /// awaited source is failed, or the result produced by when the mapping throws. + /// + /// + /// Thrown when the awaited source task, , or is , + /// or when returns . + /// + /// + /// + /// var next = await resultTask.TryAsync( + /// x => GetValueAsync(x), + /// exception => Failure.General("Mapping failed.")); + /// + /// + public Task> TryAsync( + Func> mapAsync, + Func> onException + ) + { + ArgumentNullException.ThrowIfNull(sourceTask); + ArgumentNullException.ThrowIfNull(mapAsync); + ArgumentNullException.ThrowIfNull(onException); + + return TryCore(sourceTask, source => source.TryAsync(mapAsync, onException)); + } + } + + private static FailOr Fail(FailOr source) => + FailOr.Fail(source.Failures); + + private static FailOr Exceptional(Exception exception) => + FailOr.Fail(Failure.Exceptional(exception)); + + private static FailOr TryCore( + TSource value, + Func map, + Func> onException + ) + { + try + { + return FailOr.Success(map(value)); + } + catch (Exception exception) + { + return onException(exception); + } + } + + private static async Task> TryMapAsync( + TSource value, + Func> mapAsync, + Func> onException + ) + { + Task resultTask; + + try + { + resultTask = mapAsync(value); + } + catch (Exception exception) + { + return onException(exception); + } + + ArgumentNullException.ThrowIfNull(resultTask); + + try + { + return FailOr.Success(await resultTask.ConfigureAwait(false)); + } + catch (Exception exception) + { + return onException(exception); + } + } + + private static async Task> TryCore( + Task> sourceTask, + Func, Task>> then + ) + { + var source = await sourceTask.ConfigureAwait(false); + return await then(source).ConfigureAwait(false); + } +} diff --git a/tests/FailOr.Tests/FailOrTryTests.cs b/tests/FailOr.Tests/FailOrTryTests.cs new file mode 100644 index 0000000..e284c5f --- /dev/null +++ b/tests/FailOr.Tests/FailOrTryTests.cs @@ -0,0 +1,201 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace FailOr.Tests; + +public class FailOrTryTests +{ + [Test] + [Arguments(1, 2)] + [Arguments(5, 6)] + public async Task Try_map_transforms_successful_values(int value, int expected) + { + var result = FailOr.Success(value).Try(x => x + 1); + + await ThenAssertions.AssertSuccess(result, expected); + } + + [Test] + [Arguments(1, 3)] + [Arguments(5, 7)] + public async Task Try_with_exception_handler_transforms_successful_values_without_invoking_handler( + int value, + int expected + ) + { + var calls = 0; + + var result = FailOr + .Success(value) + .Try( + x => x + 2, + _ => + { + calls++; + return 99; + } + ); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(0); + await ThenAssertions.AssertSuccess(result, expected); + } + + [Test] + [Arguments(1, 4)] + [Arguments(5, 8)] + public async Task TryAsync_map_transforms_successful_values(int value, int expected) + { + var result = await FailOr.Success(value).TryAsync(x => Task.FromResult(x + 3)); + + await ThenAssertions.AssertSuccess(result, expected); + } + + [Test] + [Arguments(1, 5)] + [Arguments(5, 9)] + public async Task TryAsync_with_exception_handler_transforms_successful_values_without_invoking_handler( + int value, + int expected + ) + { + var calls = 0; + + var result = await FailOr + .Success(value) + .TryAsync( + x => Task.FromResult(x + 4), + _ => + { + calls++; + return 99; + } + ); + + using var _ = Assert.Multiple(); + await Assert.That(calls).IsEqualTo(0); + await ThenAssertions.AssertSuccess(result, expected); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.DirectFailureShortCircuitCases))] + public async Task Failed_sources_short_circuit_all_direct_try_overloads( + string operation, + Func, InvocationCounter, Task>> invoke + ) + { + var failure = Failure.General($"{operation} failed"); + var source = FailOr.Fail(failure); + var counter = new InvocationCounter(); + + var result = await invoke(source, counter); + + using var _ = Assert.Multiple(); + await Assert.That(counter.Calls).IsEqualTo(0); + await ThenAssertions.AssertFailure(result, failure); + } + + [Test] + public async Task Try_converts_delegate_exceptions_to_exceptional_failures_by_default() + { + var expected = new InvalidOperationException("Try failed"); + Func map = _ => throw expected; + + var result = FailOr.Success(1).Try(map); + + await TryAssertions.AssertExceptionalFailure(result, expected); + } + + [Test] + public async Task TryAsync_converts_delegate_exceptions_to_exceptional_failures_by_default() + { + var expected = new InvalidOperationException("TryAsync failed"); + Func> mapAsync = _ => Task.FromException(expected); + + var result = await FailOr.Success(1).TryAsync(mapAsync); + + await TryAssertions.AssertExceptionalFailure(result, expected); + } + + [Test] + public async Task Try_uses_custom_exception_mapping() + { + var expected = new InvalidOperationException("Try custom failed"); + var customFailure = Failure.General("mapping failed"); + Exception? observed = null; + Func map = _ => throw expected; + + var result = FailOr + .Success(1) + .Try( + map, + exception => + { + observed = exception; + return customFailure; + } + ); + + using var _ = Assert.Multiple(); + await Assert.That(ReferenceEquals(observed, expected)).IsTrue(); + await ThenAssertions.AssertFailure(result, customFailure); + } + + [Test] + public async Task TryAsync_uses_custom_exception_mapping() + { + var expected = new InvalidOperationException("TryAsync custom failed"); + var customFailure = Failure.General("mapping failed"); + Exception? observed = null; + Func> mapAsync = _ => Task.FromException(expected); + + var result = await FailOr + .Success(1) + .TryAsync( + mapAsync, + exception => + { + observed = exception; + return customFailure; + } + ); + + using var _ = Assert.Multiple(); + await Assert.That(ReferenceEquals(observed, expected)).IsTrue(); + await ThenAssertions.AssertFailure(result, customFailure); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.DirectNullSelectorCases))] + public async Task Null_selectors_throw_for_direct_try_overloads( + string operation, + Action invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.DirectNullOnExceptionCases))] + public async Task Null_on_exception_handlers_throw_for_direct_try_overloads( + string operation, + Action invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.DirectNullTaskCases))] + public async Task Null_tasks_throw_for_direct_async_try_overloads( + string operation, + Func invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } +} diff --git a/tests/FailOr.Tests/TaskFailOrTryTests.cs b/tests/FailOr.Tests/TaskFailOrTryTests.cs new file mode 100644 index 0000000..86193e8 --- /dev/null +++ b/tests/FailOr.Tests/TaskFailOrTryTests.cs @@ -0,0 +1,70 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace FailOr.Tests; + +public class TaskFailOrTryTests +{ + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.LiftedParityCases))] + public async Task Lifted_try_overloads_match_direct_behavior( + string operation, + FailOr source, + Func, Task>> direct, + Func>, Task>> lifted, + FailOr expected + ) + { + var directResult = await direct(source); + var liftedResult = await lifted(Task.FromResult(source)); + + using var _ = Assert.Multiple(); + await ThenAssertions.AssertEquivalent(directResult, expected); + await ThenAssertions.AssertEquivalent(liftedResult, expected); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.LiftedNullSourceCases))] + public async Task Null_source_tasks_throw_for_lifted_try_overloads( + string operation, + Action invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.LiftedNullSelectorCases))] + public async Task Null_selectors_throw_for_lifted_try_overloads( + string operation, + Action invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.LiftedNullOnExceptionCases))] + public async Task Null_on_exception_handlers_throw_for_lifted_try_overloads( + string operation, + Action invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } + + [Test] + [MethodDataSource(typeof(TryTestData), nameof(TryTestData.LiftedNullTaskCases))] + public async Task Null_tasks_throw_for_lifted_async_try_overloads( + string operation, + Func invoke, + string parameterName + ) + { + await Assert.That(invoke).Throws().WithParameterName(parameterName); + } +} diff --git a/tests/FailOr.Tests/TryTestData.cs b/tests/FailOr.Tests/TryTestData.cs new file mode 100644 index 0000000..c4388e8 --- /dev/null +++ b/tests/FailOr.Tests/TryTestData.cs @@ -0,0 +1,343 @@ +namespace FailOr.Tests; + +public static class TryTestData +{ + public static IEnumerable< + Func<(string Operation, Func, InvocationCounter, Task>> Invoke)> + > DirectFailureShortCircuitCases() + { + yield return () => + ( + "Try map", + (source, counter) => Task.FromResult(source.Try(x => x + counter.Increment())) + ); + yield return () => + ( + "Try map custom", + (source, counter) => + Task.FromResult( + source.Try(x => x + counter.Increment(), _ => counter.Increment()) + ) + ); + yield return () => + ( + "TryAsync map", + (source, counter) => source.TryAsync(x => Task.FromResult(x + counter.Increment())) + ); + yield return () => + ( + "TryAsync map custom", + (source, counter) => + source.TryAsync( + x => Task.FromResult(x + counter.Increment()), + _ => counter.Increment() + ) + ); + } + + public static IEnumerable< + Func<(string Operation, Action Invoke, string ParameterName)> + > DirectNullSelectorCases() + { + yield return () => ("Try map", () => FailOr.Success(1).Try((Func)null!), "map"); + yield return () => + ("Try map custom", () => FailOr.Success(1).Try((Func)null!, _ => 1), "map"); + yield return () => + ( + "TryAsync map", + () => FailOr.Success(1).TryAsync((Func>)null!), + "mapAsync" + ); + yield return () => + ( + "TryAsync map custom", + () => FailOr.Success(1).TryAsync((Func>)null!, _ => 1), + "mapAsync" + ); + } + + public static IEnumerable< + Func<(string Operation, Action Invoke, string ParameterName)> + > DirectNullOnExceptionCases() + { + yield return () => + ( + "Try map custom", + () => FailOr.Success(1).Try(x => x + 1, (Func>)null!), + "onException" + ); + yield return () => + ( + "TryAsync map custom", + () => + FailOr + .Success(1) + .TryAsync(x => Task.FromResult(x + 1), (Func>)null!), + "onException" + ); + } + + public static IEnumerable< + Func<(string Operation, Func Invoke, string ParameterName)> + > DirectNullTaskCases() + { + yield return () => + { + Func> mapAsync = _ => null!; + + return ("TryAsync map", () => FailOr.Success(1).TryAsync(mapAsync), "resultTask"); + }; + yield return () => + { + Func> mapAsync = _ => null!; + + return ( + "TryAsync map custom", + () => FailOr.Success(1).TryAsync(mapAsync, _ => 1), + "resultTask" + ); + }; + } + + public static IEnumerable< + Func<( + string Operation, + FailOr Source, + Func, Task>> Direct, + Func>, Task>> Lifted, + FailOr Expected + )> + > LiftedParityCases() + { + yield return () => + ( + "Try map success", + FailOr.Success(1), + source => Task.FromResult(source.Try(x => x + 1)), + sourceTask => sourceTask.Try(x => x + 1), + FailOr.Success(2) + ); + yield return () => + ( + "Try map custom success", + FailOr.Success(1), + source => Task.FromResult(source.Try(x => x + 2, _ => 99)), + sourceTask => sourceTask.Try(x => x + 2, _ => 99), + FailOr.Success(3) + ); + yield return () => + ( + "TryAsync map success", + FailOr.Success(1), + source => source.TryAsync(x => Task.FromResult(x + 3)), + sourceTask => sourceTask.TryAsync(x => Task.FromResult(x + 3)), + FailOr.Success(4) + ); + yield return () => + ( + "TryAsync map custom success", + FailOr.Success(1), + source => source.TryAsync(x => Task.FromResult(x + 4), _ => 99), + sourceTask => sourceTask.TryAsync(x => Task.FromResult(x + 4), _ => 99), + FailOr.Success(5) + ); + yield return () => + { + var failure = Failure.General("failed"); + var source = FailOr.Fail(failure); + + return ( + "Try map failure", + source, + sourceResult => Task.FromResult(sourceResult.Try(x => x + 1)), + sourceTask => sourceTask.Try(x => x + 1), + source + ); + }; + yield return () => + { + var failure = Failure.General("failed async"); + var source = FailOr.Fail(failure); + + return ( + "TryAsync map failure", + source, + sourceResult => sourceResult.TryAsync(x => Task.FromResult(x + 1)), + sourceTask => sourceTask.TryAsync(x => Task.FromResult(x + 1)), + source + ); + }; + yield return () => + { + var exception = new InvalidOperationException("Try map exception"); + var expected = FailOr.Fail(Failure.Exceptional(exception)); + Func map = _ => throw exception; + + return ( + "Try map exception", + FailOr.Success(1), + source => Task.FromResult(source.Try(map)), + sourceTask => sourceTask.Try(map), + expected + ); + }; + yield return () => + { + var exception = new InvalidOperationException("Try map custom exception"); + var expected = FailOr.Fail(Failure.General("mapping failed")); + Func map = _ => throw exception; + + return ( + "Try map custom exception", + FailOr.Success(1), + source => Task.FromResult(source.Try(map, _ => expected)), + sourceTask => sourceTask.Try(map, _ => expected), + expected + ); + }; + yield return () => + { + var exception = new InvalidOperationException("TryAsync map exception"); + var expected = FailOr.Fail(Failure.Exceptional(exception)); + Func> mapAsync = _ => Task.FromException(exception); + + return ( + "TryAsync map exception", + FailOr.Success(1), + source => source.TryAsync(mapAsync), + sourceTask => sourceTask.TryAsync(mapAsync), + expected + ); + }; + yield return () => + { + var exception = new InvalidOperationException("TryAsync map custom exception"); + var expected = FailOr.Fail(Failure.General("mapping failed")); + Func> mapAsync = _ => Task.FromException(exception); + + return ( + "TryAsync map custom exception", + FailOr.Success(1), + source => source.TryAsync(mapAsync, _ => expected), + sourceTask => sourceTask.TryAsync(mapAsync, _ => expected), + expected + ); + }; + } + + public static IEnumerable< + Func<(string Operation, Action Invoke, string ParameterName)> + > LiftedNullSourceCases() + { + yield return () => ("Try map", () => ((Task>)null!).Try(_ => 1), "sourceTask"); + yield return () => + ("Try map custom", () => ((Task>)null!).Try(_ => 1, _ => 1), "sourceTask"); + yield return () => + ( + "TryAsync map", + () => ((Task>)null!).TryAsync(_ => Task.FromResult(1)), + "sourceTask" + ); + yield return () => + ( + "TryAsync map custom", + () => ((Task>)null!).TryAsync(_ => Task.FromResult(1), _ => 1), + "sourceTask" + ); + } + + public static IEnumerable< + Func<(string Operation, Action Invoke, string ParameterName)> + > LiftedNullSelectorCases() + { + yield return () => + ("Try map", () => Task.FromResult(FailOr.Success(1)).Try((Func)null!), "map"); + yield return () => + ( + "Try map custom", + () => Task.FromResult(FailOr.Success(1)).Try((Func)null!, _ => 1), + "map" + ); + yield return () => + ( + "TryAsync map", + () => Task.FromResult(FailOr.Success(1)).TryAsync((Func>)null!), + "mapAsync" + ); + yield return () => + ( + "TryAsync map custom", + () => + Task.FromResult(FailOr.Success(1)) + .TryAsync((Func>)null!, _ => 1), + "mapAsync" + ); + } + + public static IEnumerable< + Func<(string Operation, Action Invoke, string ParameterName)> + > LiftedNullOnExceptionCases() + { + yield return () => + ( + "Try map custom", + () => + Task.FromResult(FailOr.Success(1)) + .Try(x => x + 1, (Func>)null!), + "onException" + ); + yield return () => + ( + "TryAsync map custom", + () => + Task.FromResult(FailOr.Success(1)) + .TryAsync(x => Task.FromResult(x + 1), (Func>)null!), + "onException" + ); + } + + public static IEnumerable< + Func<(string Operation, Func Invoke, string ParameterName)> + > LiftedNullTaskCases() + { + yield return () => + { + Func> mapAsync = _ => null!; + + return ( + "TryAsync map", + () => Task.FromResult(FailOr.Success(1)).TryAsync(mapAsync), + "resultTask" + ); + }; + yield return () => + { + Func> mapAsync = _ => null!; + + return ( + "TryAsync map custom", + () => Task.FromResult(FailOr.Success(1)).TryAsync(mapAsync, _ => 1), + "resultTask" + ); + }; + } +} + +public static class TryAssertions +{ + public static async Task AssertExceptionalFailure( + FailOr result, + Exception expectedException + ) + { + using var _ = Assert.Multiple(); + + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Failures.Count).IsEqualTo(1); + var failure = result.Failures[0] as Failures.Exceptional; + + await Assert.That(failure).IsNotNull(); + await Assert.That(failure!.Code).IsEqualTo("Exceptional"); + await Assert.That(ReferenceEquals(failure.Exception, expectedException)).IsTrue(); + } +} From 0c48b2d8c909c3045c06b2dad2edb8739ee4d95f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:25:50 +0000 Subject: [PATCH 6/7] Tidy merged README wording Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d2d752e..edbfc64 100644 --- a/README.md +++ b/README.md @@ -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; From ef7d19ee91d1b159188d97f5f4017b6b49c81064 Mon Sep 17 00:00:00 2001 From: mark-pro <20671988+mark-pro@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:24:51 -0400 Subject: [PATCH 7/7] docs: clean up merged README formatting --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index edbfc64..ee3f945 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ var result = await FailOr.Success(10) : 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.