From 419e1d2a7ccadeac01a21a2bef9f565bca3350b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:11:50 +0000 Subject: [PATCH 1/3] Initial plan From 5ae98259d7af10e38fc9ebb669a42ae2c5a22648 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:20:45 +0000 Subject: [PATCH 2/3] feat: add try and tryasync failor helpers Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- README.md | 28 ++ docs/api-reference.md | 58 +++- src/FailOr/FailOrT.Try.cs | 298 ++++++++++++++++++++ tests/FailOr.Tests/FailOrTryTests.cs | 201 +++++++++++++ tests/FailOr.Tests/TaskFailOrTryTests.cs | 70 +++++ tests/FailOr.Tests/TryTestData.cs | 343 +++++++++++++++++++++++ 6 files changed, 997 insertions(+), 1 deletion(-) 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 6810a30..a0d2f27 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}")); +``` + ### 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..ec9d953 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -283,6 +283,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 @@ -551,7 +606,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> @@ -602,6 +657,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..b0d192a --- /dev/null +++ b/src/FailOr/FailOrT.Try.cs @@ -0,0 +1,298 @@ +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 + ) + { + try + { + var resultTask = mapAsync(value); + ArgumentNullException.ThrowIfNull(resultTask); + + return FailOr.Success(await resultTask.ConfigureAwait(false)); + } + catch (ArgumentNullException exception) when (exception.ParamName == "resultTask") + { + throw; + } + 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..672fb3a --- /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); + await Assert.That(result.Failures[0] is Failures.Exceptional).IsTrue(); + + var failure = (Failures.Exceptional)result.Failures[0]; + await Assert.That(failure.Code).IsEqualTo("Exceptional"); + await Assert.That(ReferenceEquals(failure.Exception, expectedException)).IsTrue(); + } +} From 9a82038f9403681171430898697cefa2c0c37b80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:23:47 +0000 Subject: [PATCH 3/3] refactor: polish try async handling Co-authored-by: mark-pro <20671988+mark-pro@users.noreply.github.com> --- src/FailOr/FailOrT.Try.cs | 18 ++++++++++++------ tests/FailOr.Tests/TryTestData.cs | 6 +++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/FailOr/FailOrT.Try.cs b/src/FailOr/FailOrT.Try.cs index b0d192a..d81fa2d 100644 --- a/src/FailOr/FailOrT.Try.cs +++ b/src/FailOr/FailOrT.Try.cs @@ -270,16 +270,22 @@ private static async Task> TryMapAsync( Func> onException ) { + Task resultTask; + try { - var resultTask = mapAsync(value); - ArgumentNullException.ThrowIfNull(resultTask); - - return FailOr.Success(await resultTask.ConfigureAwait(false)); + resultTask = mapAsync(value); } - catch (ArgumentNullException exception) when (exception.ParamName == "resultTask") + catch (Exception exception) { - throw; + return onException(exception); + } + + ArgumentNullException.ThrowIfNull(resultTask); + + try + { + return FailOr.Success(await resultTask.ConfigureAwait(false)); } catch (Exception exception) { diff --git a/tests/FailOr.Tests/TryTestData.cs b/tests/FailOr.Tests/TryTestData.cs index 672fb3a..c4388e8 100644 --- a/tests/FailOr.Tests/TryTestData.cs +++ b/tests/FailOr.Tests/TryTestData.cs @@ -334,10 +334,10 @@ Exception expectedException await Assert.That(result.IsFailure).IsTrue(); await Assert.That(result.Failures.Count).IsEqualTo(1); - await Assert.That(result.Failures[0] is Failures.Exceptional).IsTrue(); + var failure = result.Failures[0] as Failures.Exceptional; - var failure = (Failures.Exceptional)result.Failures[0]; - await Assert.That(failure.Code).IsEqualTo("Exceptional"); + await Assert.That(failure).IsNotNull(); + await Assert.That(failure!.Code).IsEqualTo("Exceptional"); await Assert.That(ReferenceEquals(failure.Exception, expectedException)).IsTrue(); } }