Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 53 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,38 @@ var result = await FailOr.Success("42")
.ThenAsync(ParseNumberAsync);
```

### Run a side effect and preserve the success

```csharp
public FailOr<TSource> ThenDo(Action<TSource> 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<FailOr<TSource>> ThenDoAsync(Func<TSource, Task> 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.
Expand Down Expand Up @@ -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<FailOr<T>>
Expand All @@ -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<FailOr<TSource>> ThenDo(Action<TSource> action)
public Task<FailOr<TSource>> ThenDoAsync(Func<TSource, Task> actionAsync)
```

Intent:
Observe or audit the successful value from a `Task<FailOr<T>>` 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:
Expand All @@ -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.
Expand Down
111 changes: 111 additions & 0 deletions src/FailOr/FailOrT.Then.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,58 @@ Func<TSource, Task<FailOr<TResult>>> bindAsync
: ThenBindAsync(source.UnsafeUnwrap(), bindAsync);
}

/// <summary>
/// Runs a side effect for a successful value while preserving the original result.
/// </summary>
/// <param name="action">The side effect to run when the source is successful.</param>
/// <returns>
/// The original success unchanged, or the original failures when the source is failed.
/// </returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="action"/> is <see langword="null"/>.
/// </exception>
/// <example>
/// <code>
/// var next = result.ThenDo(x => Console.WriteLine(x));
/// </code>
/// </example>
public FailOr<TSource> ThenDo(Action<TSource> action)
{
ArgumentNullException.ThrowIfNull(action);

if (source.IsFailure)
{
return source;
}

action(source.UnsafeUnwrap());
return source;
}

/// <summary>
/// Asynchronously runs a side effect for a successful value while preserving the original result.
/// </summary>
/// <param name="actionAsync">The asynchronous side effect to run when the source is successful.</param>
/// <returns>
/// A task producing the original success unchanged, or the original failures when the source is failed.
/// </returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="actionAsync"/> is <see langword="null"/> or returns <see langword="null"/>.
/// </exception>
/// <example>
/// <code>
/// var next = await result.ThenDoAsync(x => WriteAsync(x));
/// </code>
/// </example>
public Task<FailOr<TSource>> ThenDoAsync(Func<TSource, Task> actionAsync)
{
ArgumentNullException.ThrowIfNull(actionAsync);

return source.IsFailure
? Task.FromResult(source)
: ThenDoAsyncCore(source.UnsafeUnwrap(), actionAsync);
}

/// <summary>
/// Returns the current success unchanged, or the provided alternative result when the source is failed.
/// </summary>
Expand Down Expand Up @@ -311,6 +363,53 @@ Func<TSource, Task<FailOr<TResult>>> bindAsync
return ThenCore(sourceTask, source => source.ThenAsync(bindAsync));
}

/// <summary>
/// Runs a side effect for the successful value of a task-wrapped result while preserving the original result.
/// </summary>
/// <param name="action">The side effect to run when the awaited source is successful.</param>
/// <returns>
/// A task producing the original success unchanged, or the original failures when the awaited source is failed.
/// </returns>
/// <exception cref="ArgumentNullException">
/// Thrown when the awaited source task or <paramref name="action"/> is <see langword="null"/>.
/// </exception>
/// <example>
/// <code>
/// var next = await resultTask.ThenDo(x => Console.WriteLine(x));
/// </code>
/// </example>
public Task<FailOr<TSource>> ThenDo(Action<TSource> action)
{
ArgumentNullException.ThrowIfNull(sourceTask);
ArgumentNullException.ThrowIfNull(action);

return ThenCore(sourceTask, source => Task.FromResult(source.ThenDo(action)));
}

/// <summary>
/// Asynchronously runs a side effect for the successful value of a task-wrapped result while preserving the original result.
/// </summary>
/// <param name="actionAsync">The asynchronous side effect to run when the awaited source is successful.</param>
/// <returns>
/// A task producing the original success unchanged, or the original failures when the awaited source is failed.
/// </returns>
/// <exception cref="ArgumentNullException">
/// Thrown when the awaited source task or <paramref name="actionAsync"/> is <see langword="null"/>,
/// or when <paramref name="actionAsync"/> returns <see langword="null"/>.
/// </exception>
/// <example>
/// <code>
/// var next = await resultTask.ThenDoAsync(x => WriteAsync(x));
/// </code>
/// </example>
public Task<FailOr<TSource>> ThenDoAsync(Func<TSource, Task> actionAsync)
{
ArgumentNullException.ThrowIfNull(sourceTask);
ArgumentNullException.ThrowIfNull(actionAsync);

return ThenCore(sourceTask, source => source.ThenDoAsync(actionAsync));
}

/// <summary>
/// Returns the awaited success unchanged, or the provided alternative result when the awaited source is failed.
/// </summary>
Expand Down Expand Up @@ -455,6 +554,18 @@ Func<TSource, Task<FailOr<TResult>>> bindAsync
return await resultTask.ConfigureAwait(false);
}

private static async Task<FailOr<TSource>> ThenDoAsyncCore<TSource>(
TSource value,
Func<TSource, Task> actionAsync
)
{
var resultTask = actionAsync(value);
ArgumentNullException.ThrowIfNull(resultTask);

await resultTask.ConfigureAwait(false);
return FailOr.Success(value);
}

private static async Task<FailOr<TSource>> IfFailThenAsyncCore<TSource>(
Func<Task<FailOr<TSource>>> alternativeAsync
)
Expand Down
91 changes: 91 additions & 0 deletions tests/FailOr.Tests/FailOrThenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -77,6 +120,54 @@ string parameterName
await Assert.That(invoke).Throws<ArgumentNullException>().WithParameterName(parameterName);
}

[Test]
[MethodDataSource(typeof(ThenTestData), nameof(ThenTestData.DirectNullTaskCases))]
public async Task Null_tasks_throw_for_direct_async_ThenDo_overloads(
string operation,
Func<Task> invoke,
string parameterName
)
{
await Assert.That(invoke).Throws<ArgumentNullException>().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(
Expand Down
Loading