Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added convenience methods for easier Monad creation and Monad chaining #258

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from

Conversation

julianthurner
Copy link

@julianthurner julianthurner commented Feb 4, 2025

I added some convenience methods to the library. The Option class receives a few extension methods that allow for chaining async conversions:

var optional = await Optional.FromValue(42)
    .Convert(async x => x > 10 ? Optional.FromValue(x * 2.0 - 20) : Optional<double>.None)
    .Convert(async x => x > 0 ? Optional.FromValue("Success") : Optional<string>.None);

Console.WriteLine(optional.HasValue ? optional.Value : "Optional has no value");

This is really useful if you work with databases where data is fetched / transformed multiple times asynchronously before being filled into a DTO.
The same thing for Result:

var result = await Result.FromValue(42)
    .Convert(async x => x > 10 ? Result.FromValue(x * 2.0 - 20) : Result.FromException<double>(new Exception("x too small")))
    .Convert(async x => x > 0 ? Result.FromValue("Success") : Result.FromException<string>(new Exception("x less than zero")));

Console.WriteLine(result.IsSuccessful ? result : $"Failed with exception: {result.Error}");

This also forwards the exception up the chain just like with regular monads.

There's also overloads for converting Option ↔ Result in both directions if there's need for conversion within a chain.

var optionalFromResult = await Task.FromResult(Result.FromValue<int, TestError>(42))
    .TryGet()
    .Convert(async x => x > 10 ? Optional.FromValue(x) : Optional<int>.None);
Console.WriteLine(optionalFromResult.HasValue ? optionalFromResult.Value : "Optional has no value");

var resultFromOptional = await Task.FromResult(Optional.FromValue(42))
    .ToResult()
    .Convert(async x => x > 10 ? Result.FromValue("Success") : Result.FromException<string>(new Exception("x too small")));
Console.WriteLine(resultFromOptional.IsSuccessful ? resultFromOptional.Value : $"Failed with exception: {resultFromOptional.Error}");

Also, I added some additional convenience methods for creating Options and Results statically:

var optionalWithValue = Optional.FromValue(42);
var errorCodeResultWithValue = Result.FromValue<int, TestError>(42);
var errorCodeResultWithError = Result.FromError<int, TestError>(TestError.Error1);

@julianthurner
Copy link
Author

@dotnet-policy-service agree

@dotnet-policy-service agree

@sakno
Copy link
Collaborator

sakno commented Feb 12, 2025

@julianthurner , thanks for your contribution! According to the Contribution Guideline, could you change the target branch from master to develop?

/// <typeparam name="T">The type of the value.</typeparam>
/// <typeparam name="TResult">The type of the result of the mapping function.</typeparam>
/// <returns>The conversion result.</returns>
public static async Task<Result<TResult>> Convert<T, TResult>(this Task<Result<T>> task, Converter<T, TResult> converter)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task<Result<T>> is a subject for debates, because Task is already a monad that contains a result or an exception. Both types have the similar semantics. Thus, Task<Result<T>> is the same as Result<Result<T>>.

Copy link
Author

@julianthurner julianthurner Feb 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that point, however: If i have an async method which contains one ore more await statements, then the result of the method has to be Task<T>. And as far as I am concerned, there is no implicit conversion from Task<T> to Result<T>. Or did I overlook that?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some methods to convert task types located in Conversion class. Also, there is a method

static AwaitableResult<T> SuspendException<T>(this Task<T> task)

not yet released. With help of these methods, you can create async pipeline as follows:

Result<double> result = await SomeAsyncMethod().Convert<int, double>(i => i).SuspendException();

It's called SuspendException because the returned awaitable object never throws. Instead, the exception is returned as the part of Result<T>.

Copy link
Author

@julianthurner julianthurner Feb 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AwaitableResult seems like a neat feature!

Will this also be chainable like so?

var result = await Task.FromResult(42).SuspendException()
    .Convert(async x => x > 10 ? x * 2.0 : throw new Exception("x too small")).SuspendException()
    .Convert(async x => x > 42 ? x.ToString() : throw new Exception("x less than 42").SuspendException();

If not, I can wait until the method is implemented and then add the respective extension methods that are needed to do this.

Copy link
Collaborator

@sakno sakno Feb 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SuspendException is the last call in the chain since the Task is already acts as a monad:

Result<int> result = await Task.FromResult(42)
    .Convert(async x => x > 10 ? x * 2.0 : throw new Exception("x too small"))
    .Convert(async x => x > 42 ? x.ToString() : throw new Exception("x less than 42")
    .SuspendException();

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! Then I'll remove the commented method?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. SuspendException is available in develop branch (added in 0cb6a6a)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sakno could you take a look at the updated branch? I changed all methods to work with AwaitableResult

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SuspendException is a tail call, no need apply any transformation to it. As shown above, all transformations can be done before SuspendException.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SuspendException is a tail call, no need apply any transformation to it.

What part of the code are you referring to / what do you suggest I should change?

@sakno sakno self-assigned this Feb 12, 2025
@julianthurner julianthurner changed the base branch from master to develop February 12, 2025 13:25
@sakno
Copy link
Collaborator

sakno commented Feb 12, 2025

Changing the target branch is not enough. master cannot be used as a base branch for your branch, because master branch contains only squashed commits from develop branch. You need to create a branch from develop and cherrypick your commits, then force push.

Also, after the discussion of the API, units tests need to be added as well.

/// <param name="task">The task containing Optional value.</param>
/// <param name="converter">A mapping function to be applied to the value, if present.</param>
/// <returns>An Optional describing the result of applying a mapping function to the value of this Optional, if a value is present, otherwise <see cref="Optional{T}.None"/>.</returns>
public static async Task<Optional<TOutput>> Convert<TInput, TOutput>(this Task<Optional<TInput>> task, Converter<TInput, Task<Optional<TOutput>>> converter)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is also redundant, because Convert is already defined for generic Task<T> in DotNext.Threading.Tasks.Conversion static class.

Copy link
Author

@julianthurner julianthurner Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry it took me a while to find time again, I think this method is not completely redundant because omitting it means having to handle the Optional yourself every time instead of being able to rely on the monad chain handling it which defeats the whole purpose of the chain (see examples below). It also means that if I use static conversion methods which I might re-use elsewhere in the code instead of lambdas, I have to force Optional<TInput> as input type instead of being able to simply accept TInput.

Without extension method:

var optional = await Task.FromResult(Optional.FromValue(42))
    .Convert(async x => x > 10 ? x * 2.0 - 20 : Optional<double>.None)
    .Convert(async x => x > 0 ? "Success" : Optional<string>.None);

With extension method:

var optional = await Task.FromResult(Optional.From(42))
    .Convert(async x => x.HasValue && x.Value > 10 ? x.Value * 2.0 - 20 : Optional<double>.None)
    .Convert(async x => x.HasValue && x.Value > 0 ? "Success" : Optional<string>.None);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed method has no cancellation token support. Since 5.19.0 that is just released, there is Convert with it:

Task<Optional<TOutput>> Convert<TInput, TOutput>(this Task<Optional<TInput>> task,
        Func<TInput, CancellationToken, Task<TOutput>> converter, CancellationToken token = default)

@sakno
Copy link
Collaborator

sakno commented Mar 4, 2025

I would suggest to review the proposed API again from your side, since 5.19.0 has been released:

  1. Some factory method ✔️
  2. SuspendException to return Result<T> or Result<T, TError> ✔️
  3. Async Convert with cancellation support ✔️
  4. Flatten to convert Task<Optional<T>> to Task<T> ✔️

@julianthurner
Copy link
Author

I would suggest to review the proposed API again from your side, since 5.19.0 has been released:

  1. Some factory method ✔️
  2. SuspendException to return Result<T> or Result<T, TError> ✔️
  3. Async Convert with cancellation support ✔️

Looks nice👍I will review the changes and adjust them accordingly, sometime this week probably.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants