Minimal implementation of result monad, it is:
- Minimal implementation code: Short but effective, holding the minimal required data, without using any conditional, easy to read and understand.
- Minimal client boilerplate code: Less verbose and noisy, easy to read and understand.
Relevant design decisions:
-
Result types are implemented with classes: Chosen minimalism over efficiency. Structs are more efficient but introduce important limitations:
- Structs have no inheritance, so the current design (classes Result, Success and Failure) can not be used. Use of inheritance allows a cleaner and shorter code, without using any conditional, and enable the use of cast operator overloads that reduce client boilerplate code.
- Inheritance problem can not be fixed with interfaces (implemented by structs) because they introduce boxing, losing structs efficiency. Additionally, interfaces do not support cast operation overloads.
-
Some use cases are not implemented because they do not add significant advantages. In particular:
- A result without success or failure data: This use case can be well covered returning a simple boolean.
- A result with only success data: This use case can be well covered returning a nullable value.
- A result with a typed set of errors (like Result<TData, TError1, TError2>): It overcomplicates implementation, it forces to support different error set sizes (for 3, 4 and more errors), it introduce more client boilerplate code (because this large type declaration should be propagated along call stack) and it can be well covered using a more appropiate error type.
- A result that holds internally a collection of errors (like ReadOnlyCollection in type Result): It overcomplicates implementation and it can be well covered using Result<ReadOnlyCollection> when really needed.
- A result with a default error type (like string): Any default type potentially assumes behaviours not needed by client code. Remember, this is a minimal implementation.
-
Any type can be used to represent errors: Results not only do not need to assume any behaviour about error types (the only justification for introducing a base error class) but also errors are concepts that belongs to client code domain, not result library domain.
There are 3 Result types:
Result<TError>
: Represents a result without success data, only error data.Result<TData,TError>
: Represents a result with success and error data.Result
: Hold helper types and methods for creating result objects in two corner cases:- A success
Result<TError>
. - A
Result<TData,TError>
whenTData = TError
.
- A success
Following code shows a method that returns a Result
object:
enum ErrorCodes { InvalidArgument, EntityNotFound }
Result<ErrorCodes> UpdateEntity(string? id)
{
if (id == null)
return ErrorCodes.InvalidArgument;
var entity = GetEntity(id);
if (entity == null)
return ErrorCodes.EntityNotFound;
UpdateEntity(entity);
return Result.Success();
}
Following code shows a method that receive a Result
object:
record Error(ErrorCodes Code, string Message);
Result<Error> SomeBusinessLogic(string? entityId)
{
var result = UpdateEntity(entityId);
if (result.IsFailure())
return new Error(result.Error, "Failed to update entity");
return Result.Success();
}
Implicit cast operators true/false are implemented, so we can use:
record Error(ErrorCodes Code, string Message);
Result<Error> SomeBusinessLogic(string? entityId)
{
var result = UpdateEntity(entityId);
if (!result)
return new Error(result.Error, "Failed to update entity");
return Result.Success();
}
Use of getter Error
can produce an exception if called with a success Result
. A more secure alternative is:
record Error(ErrorCodes Code, string Message);
Result<Error> SomeBusinessLogic(string? entityId)
{
var result = UpdateEntity(entityId);
if (result.IsFailure(out var code))
return new Error(code, "Failed to update entity");
return Result.Success();
}
Previous logic can be implemented using a continuation:
record Error(ErrorCodes Code, string Message);
Result<Error> SomeBusinessLogic(string? entityId)
=> UpdateEntity(entityId)
.MapFailure(errorCode => new Error(code, "Failed to update entity"));
Following code shows a method that returns a result:
enum ErrorCodes { InvalidArgument, EntityNotFound }
Result<Entity,ErrorCodes> UpdateEntity(string? id)
{
if (id == null)
return ErrorCodes.InvalidArgument;
var entity = GetEntity(id);
if (entity == null)
return ErrorCodes.EntityNotFound;
UpdateEntity(entity);
return entity;
}
There are some groups of methods in types Result<TError>
and Result<TData,TError>
:
-
Getters: Properties
Data
andError
. They introduce a potencial invalid use case that can not be avoided at compile time. This use case corresponds to a program bug, so it is signaled at execution time throwing exceptionInvalidOperationException
. -
Simple validations: Methods
IsSuccess()
andIsFailure()
, and operatorstrue
andfalse
. -
Validations with extractions: Methods
IsSuccess(out TData data)
anIsFailure(out TError error)
. Allow secure access to result internal data. -
Continuations:
- Methods
On
,OnSuccess
andOnFailure
: Allow perform and action when result is success or failure without modifying the result. - Methods
Map
,MapSuccess
andMapFailure
: Allow perform a function producing a new result, potentially of a new type. - Method
TrimSuccess
: Creates an equivalentResult<TError>
from aResult<TData,TResult>
.
- Methods
The spirit of this library is to be minimal, it is, include the bare minimum for handling result monads. Instead of including large sets of methods (and they corresponding large sets of tests), this library encorage users to implement extensions when needed. That said, an small set of extensions is included, so library users can used them and have a reference to implement more.
Following are examples of extensions commonly included in other libraries and how they can be implemented.
Extensions for calling code that can produce a value or an exception, and map them to a Result. A possible implementation could be:
public static class ResultExtensions
{
#region No Success Data
public static Result<TError> Try<TException, TError>(Action action, Func<TException, TError> mapException)
where TException : Exception
{
try { action(); return Success(); }
catch (TException exception) { return mapException(exception); }
}
public static Result<TError> Try<TException, TError>(Func<Result<TError>> func, Func<TException, TError> mapException)
where TException : Exception
{
try { return func(); }
catch (TException exception) { return mapException(exception); }
}
#endregion
#region With Success Data
public static Result<TData, TError> Try<TData, TException, TError>(Func<Result<TData, TError>> func, Func<TException, TError> mapException)
where TException : Exception
{
try { return func(); }
catch (TException exception) { return mapException(exception); }
}
#endregion
}
Extensions for calling a predicate and map it result to an object Result<TError>
. A possible implementation could be:
public static class ResultExtensions
{
public static Result<TError> SuccessIf<TError>(Func<bool> pred, TError error)
=> pred() ? Result.Success() : error;
}
Extensions with method/lambda parameters that return tasks. A possible implementation could be:
public static class ResultExtensions
{
public static Task<Result<TError>> MapSuccess<TError>(this Result<TError> result, Func<Task<Result<TError>>> func)
=> result.Map(func, error => Task.FromResult<Result<TError>>(error));
}