oHTGo / error-or

A simple, fluent discriminated union of an error or a result.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

drawing

NuGet

Build publish ErrorOr to nuget

GitHub contributors GitHub Stars GitHub license codecov

A simple, fluent discriminated union of an error or a result.

dotnet add package ErrorOr

Give it a star ⭐!

Loving it? Show your support by giving this project a star!

Getting Started

Single Error

This πŸ‘‡πŸ½

User GetUser(Guid id = default)
{
    if (id == default)
    {
        throw new ValidationException("Id is required");
    }

    return new User(Name: "Amichai");
}
try
{
    var user = GetUser();
    Console.WriteLine(user.Name);
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}

Turns into this πŸ‘‡πŸ½

ErrorOr<User> GetUser(Guid id = default)
{
    if (id == default)
    {
        return Error.Validation("Id is required");
    }

    return new User(Name: "Amichai");
}
errorOrUser.SwitchFirst(
    user => Console.WriteLine(user.Name),
    error => Console.WriteLine(error.Description));

This πŸ‘‡πŸ½

void AddUser(User user)
{
    if (!_users.TryAdd(user))
    {
        throw new Exception("Failed to add user");
    }
}

Turns into this πŸ‘‡πŸ½

ErrorOr<Created> AddUser(User user)
{
    if (!_users.TryAdd(user))
    {
        return Error.Failure(description: "Failed to add user");
    }

    return Results.Created;
}

Multiple Errors

Internally, the ErrorOr object has a list of Errors, so if you have multiple errors, you don't need to compromise and have only the first one.

public class User
{
    public string Name { get; }

    private User(string name)
    {
        Name = name;
    }

    public static ErrorOr<User> Create(string name)
    {
        List<Error> errors = new();

        if (name.Length < 2)
        {
            errors.Add(Error.Validation(description: "Name is too short"));
        }

        if (name.Length > 100)
        {
            errors.Add(Error.Validation(description: "Name is too long"));
        }

        if (string.IsNullOrWhiteSpace(name))
        {
            errors.Add(Error.Validation(description: "Name cannot be empty or whitespace only"));
        }

        if (errors.Count > 0)
        {
            return errors;
        }

        return new User(firstName, lastName);
    }
}
public async Task<ErrorOr<User>> CreateUserAsync(string name)
{
    if (await _userRepository.GetAsync(name) is User user)
    {
        return Error.Conflict("User already exists");
    }

    var errorOrUser = User.Create("Amichai");

    if (errorOrUser.IsError)
    {
        return errorOrUser.Errors;
    }

    await _userRepository.AddAsync(errorOrUser.Value);
    return errorOrUser.Value;
}

A more practical example

[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid Id)
{
    var getUserQuery = new GetUserQuery(Id);

    ErrorOr<User> getUserResponse = await _mediator.Send(getUserQuery);

    return getUserResponse.Match(
        user => Ok(_mapper.Map<UserResponse>(user)),
        errors => ValidationProblem(errors.ToModelStateDictionary()));
}

A nice approach, is creating a static class with the expected errors. For example:

public static partial class Errors
{
    public static class User
    {
        public static Error NotFound = Error.NotFound("User.NotFound", "User not found.");
        public static Error DuplicateEmail = Error.Conflict("User.DuplicateEmail", "User with given email already exists.");
    }
}

Which can later be used as following

User newUser = ..;
if (await _userRepository.GetByEmailAsync(newUser.email) is not null)
{
    return Errors.User.DuplicateEmail;
}

await _userRepository.AddAsync(newUser);
return newUser;

Then, in an outer layer, you can use the Error.Match method to return the appropriate HTTP status code.

return createUserResult.MatchFirst(
    user => CreatedAtRoute("GetUser", new { id = user.Id }, user),
    error => error is Errors.User.DuplicateEmail ? Conflict() : InternalServerError());

Dropping the exceptions throwing logic

You have validation logic such as MediatR behaviors, you can drop the exceptions throwing logic and simply return a list of errors from the pipeline behavior

public class ValidationBehavior<TRequest, TResult> : IPipelineBehavior<TRequest, ErrorOr<TResult>>
    where TRequest : IRequest<ErrorOr<TResult>>
{
    private readonly IValidator<TRequest>? _validator;

    public ValidationBehavior(IValidator<TRequest>? validator = null)
    {
        _validator = validator;
    }

    public async Task<ErrorOr<TResult>> Handle(
        TRequest request,
        CancellationToken cancellationToken,
        RequestHandlerDelegate<ErrorOr<TResult>> next)
    {
        if (_validator == null)
        {
            return await next();
        }

        var validationResult = _validator.Validate(request);

        if (validationResult.IsError)
        {
            return validationResult.Errors
               .ConvertAll(validationFailure => Error.Validation(
                   code: validationFailure.PropertyName,
                   description: validationFailure.ErrorMessage));
        }

        return await next();
    }
}

Usage

Creating an ErrorOr<result>

There are implicit converters from TResult, Error, List<Error> to ErrorOr<TResult>

From Value

ErrorOr<int> result = 5;
public ErrorOr<int> GetValue()
{
    return 5;
}

From Single Error

ErrorOr<int> result = Error.Unexpected();
public ErrorOr<int> GetValue()
{
    return Error.Unexpected();
}

From List of Errors

ErrorOr<int> result = new List<Error> { Error.Unexpected(), Error.Validation() };
public ErrorOr<int> GetValue()
{
    return new List<Error>
    {
        Error.Unexpected(),
        Error.Validation()
    };
}

Checking if the ErrorOr<result> is an error

if (errorOrResult.IsError)
{
    // errorOrResult is an error
}

Accessing the ErrorOr<result> result

Accessing the Value

ErrorOr<int> result = 5;

var value = result.Value;

Accessing the List of Errors

ErrorOr<int> result = new List<Error> { Error.Unexpected(), Error.Validation() };

List<Error> value = result.Errors; // List<Error> { Error.Unexpected(), Error.Validation() }
ErrorOr<int> result = Error.Unexpected();

List<Error> value = result.Errors; // List<Error> { Error.Unexpected() }

Accessing the First Error

ErrorOr<int> result = new List<Error> { Error.Unexpected(), Error.Validation() };

Error value = result.FirstError; // Error.Unexpected()
ErrorOr<int> result = Error.Unexpected();

Error value = result.FirstError; // Error.Unexpected()

Performing actions based on the ErrorOr<result> result

Match

Actions that return a value on the value or list of errors

string foo = errorOrString.Match(
    value => value,
    errors => $"{errors.Count} errors occurred.");

MatchFirst

Actions that return a value on the value or first error

string foo = errorOrString.MatchFirst(
    value => value,
    firstError => firstError.Description);

Switch

Actions that don't return a value on the value or list of errors

errorOrString.Switch(
    value => Console.WriteLine(value),
    errors => Console.WriteLine($"{errors.Count} errors occurred."));

SwitchFirst

Actions that don't return a value on the value or first error

errorOrString.SwitchFirst(
    value => Console.WriteLine(value),
    firstError => Console.WriteLine(firstError.Description));

Error Types

Built-in Error Types

Each error has a type out of the following options:

public enum ErrorType
{
    Failure,
    Unexpected,
    Validation,
    Conflict,
    NotFound,
}

Creating a new Error instance is done using one of the following static methods:

public static Error Error.Failure(string code, string description);
public static Error Error.Unexpected(string code, string description);
public static Error Error.Validation(string code, string description);
public static Error Error.Conflict(string code, string description);
public static Error Error.NotFound(string code, string description);

The ErrorType enum is a good way to categorize errors.

Custom error types

You can create your own error types if you would like to categorize your errors differently.

A custom error type can be created with the Custom static method

public static class MyErrorTypes
{
    const int ShouldNeverHappen = 12;
}

var error = Error.Custom(
    type: MyErrorTypes.ShouldNeverHappen,
    code: "User.ShouldNeverHappen",
    description: "A user error that should never happen");

You can use the Error.NumericType method to retrieve the numeric type of the error.

var errorMessage = Error.NumericType switch
{
    MyErrorType.ShouldNeverHappen => "Consider replacing dev team",
    _ => "An unknown error occurred.",
};

Why would I want to categorize my errors?

If you are developing a web API, it can be useful to be able to associate the type of error that occurred to the HTTP status code that should be returned.

If you don't want to categorize your errors, simply use the Error.Failure static method.

Built in result types

There are a few built in result types:

ErrorOr<Success> result = Result.Success;
ErrorOr<Created> result = Result.Created;
ErrorOr<Updated> result = Result.Updated;
ErrorOr<Deleted> result = Result.Deleted;

Which can be used as following

ErrorOr<Deleted> DeleteUser(Guid id)
{
    var user = await _userRepository.GetByIdAsync(id);
    if (user is null)
    {
        return Error.NotFound(code: "User.NotFound", description: "User not found.");
    }

    await _userRepository.DeleteAsync(user);
    return Result.Deleted;
}

How Is This Different From OneOf<T0, T1> or FluentResults?

It's similar to the others, just aims to be more intuitive and fluent. If you find yourself typing OneOf<User, DomainError> or Result.Fail<User>("failure") again and again, you might enjoy the fluent API of ErrorOr<User> (and it's also faster).

Contribution

If you have any questions, comments, or suggestions, please open an issue or create a pull request πŸ™‚

Credits

  • OneOf - An awesome library which provides F# style discriminated unions behavior for C#

License

This project is licensed under the terms of the MIT license.

About

A simple, fluent discriminated union of an error or a result.

License:MIT License


Languages

Language:C# 100.0%