Either Type Basics
Either type is coming from functional languages where exceptions are (rightfully) considered a side-effect, and therefore not appropriate to pass domain errors. Mind the difference between different kinds of errors: Some of them belong to domain, others don't. E.g. null reference exception or index out of bounds are not related to domain - they rather indicate a defect.
Either is defined as a generic type with two branches - success and failure: Either<TResult, TError>
. It can appear in two forms, where it contains an object of TResult
, or where it contains an object of TError
. It cannot appear in both states at once, or in none of them. Therefore, if one possesses an Either instance, it either contains a successfully produced result, or contains an error object.
Either and Exceptions
Either type is replacing exceptions in those scenarios where exception would represent an event important to the domain. It doesn't replace exceptions in other scenarios, though.
Story about exceptions is a long one, spanning from unwanted side-effects to plain leaky abstractions. By the way, leaky abstractions are the reason why use of the throws
keyword has faded over time in the Java language.
Either and Side Effects
It is equally interesting when it comes to side-effects, especially when combined with immutable types. In any language, functional, OOP or mixed (C#, Java, Python, included), programmers behave specifically when they know a certain type is immutable. For one thing, they sometimes tend to cache results - with full right! - which helps them avoid costly calls later, like operations that involve network calls or even database.
Caching can also be subtle, like using an in-memory object a couple of times before the operation ends. Now, if an immutable type has a separate channel for domain error results, then they will defeat the purpose of caching. Will the object we have be useful several times, or should we call the generating function every time we need its result? It is a tough question, where ignorance occasionally leads to defects in code.
Functional Either Type Implementation
That is where Either type comes to help. We can disregard its internal complexity, because it is a library type, and only focus on its API. Minimum Either type allows to:
- Map result to a different result, or result of a different type - useful to chain happy path transforms
- Handle an error, effectively turning failure into success - useful in the top level, e.g. when representing both success and failure as a HTTP response
- Transform one error into another - useful when passing layer boundaries (set of domain errors in one layer needs to be translated into set of domain errors of another layer)
The most obvious benefit from using Either is that functions that return it will explicitly state both channels over which they return a result. And, results will become stable, which means that we can freely cache them if we need so. On the other hand, binding operations on the Either type alone help avoid pollution in the rest of the code. Functions will never receive an Either, for one thing. They will be divided into those operating on a regular object (contained in the Success variant of Either), or those operating on domain error objects (contained in the Failed variant of Either). It is the binding operation on Either that chooses which of the functions will effectively be invoked. Consider the example:
var response = ReadUser(input) // returns Either<User, Error>
.Map(FindProduct) // returns Either<Product, Error>
.Map(ReadTechnicalDetails) // returns Either<ProductDetails, Error>
.Map(View) // returns Either<HttpResponse, Error>
.Handle(ErrorView); // returns HttpResponse in either case
Signatures of all methods used is straight-forward, and none of them will receive the Either type. Those methods that can detect an error, are allowed to return Either. Those that don't, will just return a plain result.
Either<User, Error> ReadUser(input);
Product FindProduct(User);
Either<ProductDetails, Error> ReadTechnicalDetails(Product);
HttpResponse View(Product);
HttpResponse ErrorView(Product);
All these disparate methods can be bound to Either, which will choose whether to effectively call them, or to keep going with what it already contains. Basically, the Map operation would pass if called on Failed, and call the operation on Success.
That is the principle which lets us only code the happy path and handle the error in the moment when it becomes possible. In most cases, it will be impossible to handle error all the way until the top-most layer is reached. Application will normally "handle" error by turning it into an error response. That scenario is precisely where Either type shines, because no other code will ever notice that errors need to be handled.
Either Type in Practice
There are scenarios, like form validation, where multiple errors need to be collected along the route. For that scenario, Either type would contain List, not just an Error. Previously proposed Either.Map function would suffice in this scenario as well, only with a modification. Common Either<Result, Error>.Map(f)
doesn't call f
in Failed state. But Either<Result, List<Error>>.Map(f)
, where f returns Either<Result, Error>
would still choose to call f
, only to see if it returned an error and to append that error to the current list.
After this analysis, it is obvious that Either type is representing a programming principle, a pattern if you like, not a solution. If any application has some specific needs, and Either fits those needs, then implementation boils down to choosing appropriate bindings which would then be applied by the Either object to target objects. Programming with Either becomes declarative. It is the duty of the caller to declare which functions apply to positive and negative scenario, and the Either object will decide whether and which function to call at run time.
Simple Example
Consider a problem of calculating an arithmetic expression. Nodes are evaluated in-depth by a calculation function, which returns Either<Value, ArithmeticError>
. Errors are like overflow, underflow, division by zero, etc. - typical domain errors. Implementing the calculator is then straight-forward: Define nodes, which can either be plain values or operations, and then implement some Evaluate
function for each of them.
// Plain value node
class Value : Node
{
private int content;
...
Either<int, Error> Evaluate() => this.content;
}
// Division node
class Division : Node
{
private Node left;
private Node right;
...
public Either<Value, ArithmeticError> Evaluate() =>
this.left.Map(value => this.Evaluate(value));
private Either<Value, ArithmeticError> Evaluate(int leftValue) =>
this.right.Map(rightValue => rightValue == 0
? Either.Fail(new DivideByZero())
: Either.Success(new Value(leftValue / rightValue));
}
...
// Consuming code
Node expression = ...;
string report = expression.Evaluate()
.Map(result => $"Result = {result}")
.Handle(error => $"ERROR: {error}");
Console.WriteLine(report);
This example demonstrates how evaluation can cause an arithmetic error to pop up at any point, and all nodes in the system would simply ignore it. Nodes will only evaluate their happy path, or generate an error themselves. Error will be considered for the first time only at the UI, when something needs to be displayed to the user.
Complex Example
In a more complicated arithmetic evaluator, one might want to see all errors, not just one. That problem requires customization on at least two accounts: (1) Either must contain a list of errors, and (2) New API must be added to combine two Either instances.
public Either<int, ArithErrorList> Combine(
Either<int, ArithErrorList> a,
Either<int, ArithErrorList> b,
Func<int, int, Either<int, ArithErrorList>> map) =>
a.Map(aValue => Combine(aValue, b, map);
private Either<int, ArithErrorList> Combine(
int aValue,
Either<int, ArithErrorList> b,
Func<int, int, Either<int, ArithErrorList>> map) =>
b.Map(bValue => map(aValue, bValue)); // retains b error list otherwise
private Either<int, ArithErrorList> Combine(
ArithErrorList aError,
Either<int, ArithErrorList> b,
Func<int, int, Either<int, ArithErrorList>> map) =>
b.MapError(bError => aError.Concat(bError))
.Map(_ => bError); // Either concatenate both errors, or just keep b error
...
// Operation implementation
class Divide : Node
{
private Node left;
private Node right;
...
public Either<int, AirthErrorList> Evaluate() =>
helper.Combine(left.Evaluate(), right.Evaluate(), this.Evaluate);
private Either<int, ArithErrorList> Evaluate(int a, int b) =>
b == 0 ? (ArithErrorList)new DivideByZero() : a / b;
}
In this implementation, the public Combine
method is the entry point which can concatenate errors from two Either instances (if both are Failed), retain one list of errors (if only one is Failed), or call the mapping function (if both are Success). Note that even the last scenario, with both Either objects being Success, can eventually produce a Failed result!
Note to Implementers
It is important to note that Combine
methods are library code. It is a general rule that cryptic, complex transforms must be hidden from the consuming code. It is only the plain and simple API that the consumer will ever see.
In that respect, the Combine
method could be an extension method attached, for example, to the Either<TResult, List<TError>>
or Either<TReuslt, ImmutableList<TError>>
type, so that it becomes available (unobtrusively!) in those cases where errors can be combined. In all other cases, when error type is not a list, the Combine
method would not be available.
Either
class seems awfully convoluted and it looks like all you want is a simple error management which you can achieve with exception handling or with a simple response object that will have a property that you can check for errors – TrashyEither
makes it syntactically impossible to forget to handle failures because it forces you to "map" failures to a valid result (like a string). Hence the convoluted implementation of the classes required byEither
(it's hard to get it). I'm sorry that I can't provide you with more information. I have looked around for more sample and insights, but I'm afraid that only a low % of the people here could answer this question. [Crossing fingers] – Through