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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ independent parts separately and combine them with `Zip`. A `Then` lambda that r
`invalid(...)` needs its type spelled out, `Then<int>(...)`, because union conversions take no part in type inference;
a step that already returns a validation needs nothing.

### Async

```csharp
Validated<User, string> user = await Login(form.Login)
.ThenAsync(login => repository.FindAsync(login)); // a dependent step that hits the database

string message = await user.MatchAsync(
async found => await GreetAsync(found),
errors => string.Join("; ", errors)); // a synchronous branch is fine too

Validated<string, string> name = await user.MapAsync(found => LoadDisplayNameAsync(found));
```

The async members are deliberately few. The delegates return `Task<T>`; the methods return `ValueTask<T>`, already
complete (and allocation-free) when the case passes through. Arguments are checked immediately; anything a delegate
throws comes out when the result is awaited, and an `OperationCanceledException` for a requested token gives a
canceled result.

### The uninitialized validation

`Validated<T, TError>` is a struct, so `default(Validated<T, TError>)` exists, for example in a new array. It is
Expand Down
10 changes: 10 additions & 0 deletions smoke/DotValid.AotSmoke/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@
Check(mixed.Sequence() == invalid("empty", "too short") && mixed.Values().Sum() == 49 && mixed.Errors().Count() == 2, "Sequence, Values, Errors");
var (passed, problems) = mixed.Partition();
Check(passed is [42, 7] && problems is ["empty", "too short"], "Partition");
var described = await age.MatchAsync(
static async value =>
{
await Task.Yield();
return $"valid {value}";
},
static errors => $"{errors.Length} errors");
Check(described == "valid 42" && await failed.MatchAsync(static _ => Task.FromResult(0), static errors => errors.Length) == 2, "MatchAsync");
Check(await age.MapAsync(static value => Task.FromResult(value + 1)) == valid(43) && await failed.MapAsync(static value => Task.FromResult(value)) == failed, "MapAsync");
Check(await Parse("7").ThenAsync(static value => Task.FromResult(Adult(value))) == invalid("too young"), "ThenAsync");

if (failures.Count > 0)
{
Expand Down
4 changes: 4 additions & 0 deletions src/DotValid/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ DotValid.Validated<T, TError>.IUnionMembers.TryGetValue(out DotValid.Invalid<TEr
DotValid.Validated<T, TError>.IUnionMembers.TryGetValue(out DotValid.Valid<T> value) -> bool
DotValid.Validated<T, TError>.IUnionMembers.Value.get -> object?
DotValid.Validated<T, TError>.Map<TResult>(System.Func<T, TResult>! map) -> DotValid.Validated<TResult, TError>
DotValid.Validated<T, TError>.MapAsync<TResult>(System.Func<T, System.Threading.Tasks.Task<TResult>!>! map) -> System.Threading.Tasks.ValueTask<DotValid.Validated<TResult, TError>>
DotValid.Validated<T, TError>.MapError<TNewError>(System.Func<TError, TNewError>! map) -> DotValid.Validated<T, TNewError>
DotValid.Validated<T, TError>.Match<TResult>(System.Func<T, TResult>! valid, System.Func<System.Collections.Immutable.ImmutableArray<TError>, TResult>! invalid) -> TResult
DotValid.Validated<T, TError>.MatchAsync<TResult>(System.Func<T, System.Threading.Tasks.Task<TResult>!>! valid, System.Func<System.Collections.Immutable.ImmutableArray<TError>, System.Threading.Tasks.Task<TResult>!>! invalid) -> System.Threading.Tasks.ValueTask<TResult>
DotValid.Validated<T, TError>.MatchAsync<TResult>(System.Func<T, System.Threading.Tasks.Task<TResult>!>! valid, System.Func<System.Collections.Immutable.ImmutableArray<TError>, TResult>! invalid) -> System.Threading.Tasks.ValueTask<TResult>
DotValid.Validated<T, TError>.OrDefault(System.Func<System.Collections.Immutable.ImmutableArray<TError>, T>! fallback) -> T
DotValid.Validated<T, TError>.OrDefault(T fallback) -> T
DotValid.Validated<T, TError>.Select<TResult>(System.Func<T, TResult>! selector) -> DotValid.Validated<TResult, TError>
DotValid.Validated<T, TError>.Then<TResult>(System.Func<T, DotValid.Validated<TResult, TError>>! next) -> DotValid.Validated<TResult, TError>
DotValid.Validated<T, TError>.ThenAsync<TResult>(System.Func<T, System.Threading.Tasks.Task<DotValid.Validated<TResult, TError>>!>! next) -> System.Threading.Tasks.ValueTask<DotValid.Validated<TResult, TError>>
DotValid.Validated<T, TError>.Validated() -> void
DotValid.Validated<T, TError>.Zip<TOther, TResult>(DotValid.Validated<TOther, TError> other, System.Func<T, TOther, TResult>! combine) -> DotValid.Validated<TResult, TError>
DotValid.Validated<T, TError>.Zip<TOther>(DotValid.Validated<TOther, TError> other) -> DotValid.Validated<(T, TOther), TError>
Expand Down
154 changes: 154 additions & 0 deletions src/DotValid/Validated.Async.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using System.Collections.Immutable;

namespace DotValid;

/// <content>
/// Asynchronous counterparts of <c>Match</c>, <c>Map</c> and <c>Then</c> (V10, minimal on purpose).
/// </content>
/// <remarks>
/// <para>
/// The delegates return <see cref="Task{TResult}"/>, so any async lambda or Task-returning method fits. The methods
/// return <see cref="ValueTask{TResult}"/>: when there is nothing to wait for, the result is already complete and
/// nothing is allocated.
/// </para>
/// <para>
/// Arguments and the uninitialized default are rejected immediately, when the method is called. Anything a delegate
/// does, including throwing before it returns a task, comes out when the result is awaited; an
/// <see cref="OperationCanceledException"/> for a requested token gives a canceled result.
/// </para>
/// </remarks>
public readonly partial struct Validated<T, TError>
{
/// <summary>
/// Asynchronously reduces this validation to a single value: <paramref name="valid"/> runs on the value,
/// <paramref name="invalid"/> on the errors. Exactly one of them runs, exactly once.
/// </summary>
/// <typeparam name="TResult">The type of the value both branches produce.</typeparam>
/// <param name="valid">Produces the value from the value of a valid validation.</param>
/// <param name="invalid">Produces the value from the errors, in order.</param>
/// <returns>The value produced by the branch that ran.</returns>
/// <exception cref="ArgumentNullException"><paramref name="valid"/> or <paramref name="invalid"/> is null.</exception>
/// <exception cref="InvalidOperationException">The validation is uninitialized (default).</exception>
public ValueTask<TResult> MatchAsync<TResult>(Func<T, Task<TResult>> valid, Func<ImmutableArray<TError>, Task<TResult>> invalid)
{
ArgumentNullException.ThrowIfNull(valid);
ArgumentNullException.ThrowIfNull(invalid);
return _case switch
{
ValidatedCase.Valid => Start(valid, _valid.Value),
ValidatedCase.Invalid => Start(invalid, _invalid.Errors),
_ => throw Uninitialized(),
};
}

/// <summary>
/// Asynchronously reduces this validation to a single value, with a synchronous <paramref name="invalid"/> branch
/// (typically turning the errors into a message). For an invalid validation the result is already complete.
/// </summary>
/// <typeparam name="TResult">The type of the value both branches produce.</typeparam>
/// <param name="valid">Produces the value from the value of a valid validation.</param>
/// <param name="invalid">Produces the value from the errors, in order.</param>
/// <returns>The value produced by the branch that ran.</returns>
/// <exception cref="ArgumentNullException"><paramref name="valid"/> or <paramref name="invalid"/> is null.</exception>
/// <exception cref="InvalidOperationException">The validation is uninitialized (default).</exception>
public ValueTask<TResult> MatchAsync<TResult>(Func<T, Task<TResult>> valid, Func<ImmutableArray<TError>, TResult> invalid)
{
ArgumentNullException.ThrowIfNull(valid);
ArgumentNullException.ThrowIfNull(invalid);
return _case switch
{
ValidatedCase.Valid => Start(valid, _valid.Value),
ValidatedCase.Invalid => Evaluate(invalid, _invalid.Errors),
_ => throw Uninitialized(),
};
}

/// <summary>
/// Asynchronously transforms the value of a valid validation; the errors of an invalid one pass through at once,
/// without waiting.
/// </summary>
/// <typeparam name="TResult">The type of the transformed value.</typeparam>
/// <param name="map">Transforms the value.</param>
/// <returns>A valid validation with the transformed value, or the same errors.</returns>
/// <exception cref="ArgumentNullException"><paramref name="map"/> is null.</exception>
/// <exception cref="InvalidOperationException">
/// The validation is uninitialized (default); or, when awaited, <paramref name="map"/> produced <see langword="null"/>.
/// </exception>
public ValueTask<Validated<TResult, TError>> MapAsync<TResult>(Func<T, Task<TResult>> map)
where TResult : notnull
{
ArgumentNullException.ThrowIfNull(map);
return _case switch
{
ValidatedCase.Valid => MapValueAsync(_valid.Value, map),
ValidatedCase.Invalid => ValueTask.FromResult(new Validated<TResult, TError>(_invalid)),
_ => throw Uninitialized(),
};

static async ValueTask<Validated<TResult, TError>> MapValueAsync(T value, Func<T, Task<TResult>> map) =>
new(new Valid<TResult>(Produced(await map(value).ConfigureAwait(false), nameof(map))));
}

/// <summary>
/// Continues a valid validation with an asynchronous step that depends on the value, for example a database check;
/// the errors of an invalid one pass through at once, without waiting. Like <see cref="Then{TResult}"/>, it never
/// accumulates errors.
/// </summary>
/// <typeparam name="TResult">The type of the value produced by the next step.</typeparam>
/// <param name="next">The next step.</param>
/// <returns>The validation produced by the next step, or the same errors.</returns>
/// <exception cref="ArgumentNullException"><paramref name="next"/> is null.</exception>
/// <exception cref="InvalidOperationException">
/// The validation is uninitialized (default); or, when awaited, <paramref name="next"/> produced an uninitialized
/// validation.
/// </exception>
public ValueTask<Validated<TResult, TError>> ThenAsync<TResult>(Func<T, Task<Validated<TResult, TError>>> next)
where TResult : notnull
{
ArgumentNullException.ThrowIfNull(next);
return _case switch
{
ValidatedCase.Valid => ThenValueAsync(_valid.Value, next),
ValidatedCase.Invalid => ValueTask.FromResult(new Validated<TResult, TError>(_invalid)),
_ => throw Uninitialized(),
};

static async ValueTask<Validated<TResult, TError>> ThenValueAsync(
T value,
Func<T, Task<Validated<TResult, TError>>> next) =>
Validated<TResult, TError>.Initialized(await next(value).ConfigureAwait(false), nameof(next));
}

// R8: a delegate's synchronous throw comes out through the ValueTask; a requested cancellation cancels it.
private static ValueTask<TResult> Start<TArg, TResult>(Func<TArg, Task<TResult>> step, TArg arg)
{
try
{
return new ValueTask<TResult>(step(arg));
}
catch (OperationCanceledException canceled) when (canceled.CancellationToken.IsCancellationRequested)
{
return ValueTask.FromCanceled<TResult>(canceled.CancellationToken);
}
catch (Exception exception)
{
return ValueTask.FromException<TResult>(exception);
}
Comment on lines +133 to +136
}

private static ValueTask<TResult> Evaluate<TArg, TResult>(Func<TArg, TResult> step, TArg arg)
{
try
{
return ValueTask.FromResult(step(arg));
}
catch (OperationCanceledException canceled) when (canceled.CancellationToken.IsCancellationRequested)
{
return ValueTask.FromCanceled<TResult>(canceled.CancellationToken);
}
catch (Exception exception)
{
return ValueTask.FromException<TResult>(exception);
}
Comment on lines +149 to +152
}
}
17 changes: 17 additions & 0 deletions tests/DotValid.PropertyTests/AllocationProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ public void Building_blocks_on_the_valid_path_allocate_nothing(int value)
Allocations.AssertNone("ValidationError", () => _ = new ValidationError("Name", "is empty"));
}

[Property(Skip = Allocations.SkipInDebug)]
public void Async_members_with_completed_tasks_allocate_nothing(Validated<int, int> validated)
{
Allocations.AssertNone(
"MatchAsync(async, async)",
() => _ = validated.MatchAsync(static _ => CompletedOne, static _ => CompletedOne).IsCompleted);
Allocations.AssertNone(
"MatchAsync(async, sync)",
() => _ = validated.MatchAsync(static _ => CompletedOne, static errors => errors.Length).IsCompleted);
Allocations.AssertNone("MapAsync", () => _ = validated.MapAsync(static _ => CompletedOne).IsCompleted);
Allocations.AssertNone("ThenAsync", () => _ = validated.ThenAsync(static _ => CompletedValidOne).IsCompleted);
}

[Property(Skip = Allocations.SkipInDebug)]
public void Comparing_allocates_nothing(Validated<int, int> validated, Validated<int, int> other)
{
Expand All @@ -124,6 +137,10 @@ public void Comparing_allocates_nothing(Validated<int, int> validated, Validated

private const string? Text = "text";

// Tasks completed in advance: any allocation in the async tests comes from the library, not from the delegate.
private static readonly Task<int> CompletedOne = Task.FromResult(1);
private static readonly Task<Validated<int, int>> CompletedValidOne = Task.FromResult(Validations.Valid<int, int>(1));

private static Validated<int, int> Succeed(int value) => valid(value);

private static Validated<int, int> Fail(Invalid<int> failure) => failure;
Expand Down
Loading
Loading