Let's first consider two well-known extremes.
Option
types
An Option
type is a type that encapsulates the notion that something may or may not exist. In languages that support Closed Algebraic Sum Types (aka Discriminated Unions aka Tagged Unions aka Variant Types), and Option
type can be very simple:
data Option a = None | Some a
That is, an Option
of a
is either a nullary data constructor representing nothingness or a wrapper around a
.
This Option
type has a couple of interesting properties: it can be viewed as a collection that has either exactly one element or is empty, i.e. similar to a list of length at-most one. What this means is that you can use all the power of the collections library, and all the knowledge you as a programmer have about collections to work with these Option
s.
For example: you want to transform the value? Just map
(in C# Select
) the Option
! You want to print it only if it exists? Just foreach
it! You have nested computations all of which may or may not return a value and need to use the value of the previous computation? Just flatMap
(in C# SelectMany
) the Options
and the result will either be the result of the entire computation if all of them return a value or None
if any of them returns no value. In other words: Option
s are composable!
Everything you know from how to handle collections transfers directly to Option
s. Note that in the above examples, there was actually never any point where you would need an if
statement or pattern matching or any other kind of conditional to check whether or not the value exists: if you foreach
over an empty collection, then simply nothing happens, if you map
or flatMap
over an empty collection, you get an empty collection back. You can almost treat the Option
as if it didn't exist, and it will just do the right thing.
More generally,Option
s are not just collections, they are monads as well, meaning you can use all the power of monads. For example, in C#, monads support the LINQ Standard Query Operators and can be used with LINQ Query Expression Syntax. You could write something like this:
var ultimateResult =
from res1 in ComputationThatMayOrMayNotReturnAResult()
from res2 in AnotherComputation(res1)
from res3 in YetAnotherComputation(res2)
select res3;
And the result will either be Some(res3)
or None
.
Option
can essentially replace any method that uses the Try…
pattern, e.g. int.TryParse(string)
.
Now, unfortunately, C# does not have Closed Algebraic Sum Types (although there are Github issues about that). But, it is possible to implement Algebraic Sum Types using nothing but simple rank-1 parametric polymorphism (aka Generics) and classical OO inheritance: the type becomes an abstract superclass and the data constructors become concrete subclasses.
In Scala, this looks something like this [Please forgive the large amount of Scala code, I don't really know C#]:
sealed trait Option[+A] {
val isPresent: Boolean
def getOrElse[B >: A](default: => B): B
def foreach[B](f: A => B): Unit
def map[B](f: A => B): Option[B]
}
case object None extends Option[Nothing] {
override final val isPresent = false
override def getOrElse[B](default: => B) = default
override def foreach[B](f: Nothing => B) = ()
override def map[B](f: Nothing => B): this.type = this
}
final case class Some[+A](value: A) extends Option[A] {
override final val isPresent = true
override def getOrElse[B >: A](default: => B): value.type = value
override def foreach[B](f: A => B) = f(value)
override def map[B](f: A => B): Some[B] = Some(f(value))
}
val twoPlusThree = Some(2).map(_ + 3)
twoPlusThree.foreach(println)
// 5
val ohOh = (None: Option[Int]).map(_ + 3)
ohOh.foreach(println)
// no output, since the `println` is never executed
Scastie link
I chose to show a Scala example first, even though the question is about C#, because it is somewhat easier to see what is going on in the Scala example. (For example, C# does not have upper bounds for generic type parameters, so we have to use A
as the parameter type for the default value in getOrElse
, which is however a contravariant position, which in turn means that Option
cannot be covariant in A
, and so on. Also, C# doesn't have a bottom type (type that is a subtype of all types), so we cannot have a single None
object, we need a separate None<T>
object for each T
, and so on.)
In C#, this could look somewhat like this:
var twoPlusThree = new Some<int>(2).Select(i => i + 3);
twoPlusThree.ForEach(System.Console.WriteLine);
// 5
var ohOh = new None<int>().Select(i => i + 3);
ohOh.ForEach(System.Console.WriteLine);
// no output, since the `WriteLine` is never executed
interface Option<T> /* : IEnumerable<T>, … */ where T : notnull
{
bool IsPresent { get; }
T GetOrElse(T defaultValue);
void ForEach(Action<T> action);
Option<U> Select<U>(Func<T, U> f) where U : notnull;
}
readonly record struct None<T> : Option<T> where T : notnull
{
public bool IsPresent { get => false; }
public T GetOrElse(T defaultValue) => defaultValue;
public void ForEach(Action<T> action) { }
public Option<U> Select<U>(Func<T, U> f) where U : notnull => new None<U>();
// In reality, you would probably have a factory method that caches one `None` for each `U`
}
readonly record struct Some<T>(T value) : Option<T> where T : notnull
{
public bool IsPresent { get => true; }
public T GetOrElse(T defaultValue) => value;
public void ForEach(Action<T> action) => action(value);
public Option<U> Select<U>(Func<T, U> f) where U : notnull => new Some<U>(f(value));
}
(Obviously, a real-world implementation would have all the IEnumerable
methods, also IComparable
would be a good idea, and we would implement Serialization and Deconstruction / Pattern Matching, etc.)
As you can see, it is structurally very similar to the Scala code. The main differences are:
- The Scala version is closed, you cannot add any further subclasses to it. (
sealed
in Scala means "can only be extended within the same compilation unit", whereas final
means "cannot be extended at all", i.e. like sealed
in C#. Since Option
is sealed
, it can only be extended within the same compilation unit, and the only two templates that extend it are Some
and None
, both of which are final
.)
- The Scala version is covariant.
- The Scala version only has a single
None
value. (Nothing
is a bottom type, i.e. it is a subtype of every type. And since Option
is covariant, Option[Nothing]
is a subtype of every Option[T]
for all T
. Therefore, a single None
can be used wherever an Option[T]
is expected.)
Either
types
So, what we had now was a type that encapsulates the notion that something either is there or it isn't there. In other words, it either is some type or it is nothing.
The other extreme is something that can be either one type or another type. This kind of type is called an Either
type. It, too, can be easily expressed as an algebraic sum type:
data Either a b = Left a | Right b
This can be implemented in the same way as Option
using inheritance:
sealed trait Either[+A, +B] {
val isLeft: Boolean
val isRight: Boolean
def getLeftOrElse[C >: A](default: => C): C
def getRightOrElse[C >: B](default: => C): C
def either[C](f: A => C)(g: B => C): C
}
final case class Left[+A](value: A) extends Either[A, Nothing] {
override final val isLeft = true
override final val isRight = false
override def getLeftOrElse[C >: A](default: => C): value.type = value
override def getRightOrElse[C](default: => C) = default
override def either[C](f: A => C)(g: Nothing => C) = f(value)
}
final case class Right[+B](value: B) extends Either[Nothing, B] {
override final val isLeft = false
override final val isRight = true
override def getLeftOrElse[C](default: => C) = default
override def getRightOrElse[C >: B](default: => C): value.type = value
override def either[C](f: Nothing => C)(g: B => C) = g(value)
}
val s: Either[String, Int] = Left("Hello")
val i: Either[String, Int] = Right(23)
println(s.either(_.length)(_ * 2))
// 5
println(i.either(_.length)(_ * 2))
// 46
Scastie link
Scala has some other syntactic niceties that we can use. For example, generic types with exactly two type parameters can be written A Foo B
instead of Foo[A, B]
. So, if we rename our Either
to Or
, we could write A Or B
as the type, which reads really nice. In fact, |
is a legal identifier like any other in Scala, so we could also name the trait |
and write A | B
.
In C#, it would look roughly like this:
Either<string, int> s = new Left<string, int>("Hello");
Either<string, int> i = new Right<string, int>(23);
System.Console.WriteLine(s.Either(x => x.Length, x => x * 2));
// 5
System.Console.WriteLine(i.Either(x => x.Length, x => x * 2));
// 46
interface Either<A, B> where A : notnull where B : notnull
{
bool IsLeft { get; }
bool IsRight { get; }
A GetLeftOrElse(A defaultValue);
B GetRightOrElse(B defaultValue);
C Either<C>(Func<A, C> f, Func<B, C> g) where C : notnull;
}
readonly record struct Left<A, B>(A value) : Either<A, B> where A : notnull where B : notnull
{
public bool IsLeft { get => true; }
public bool IsRight { get => false; }
public A GetLeftOrElse(A defaultValue) => value;
public B GetRightOrElse(B defaultValue) => defaultValue;
public C Either<C>(Func<A, C> f, Func<B, C> g) where C : notnull => f(value);
}
readonly record struct Right<A, B>(B value) : Either<A, B> where A : notnull where B : notnull
{
public bool IsLeft { get => false; }
public bool IsRight { get => true; }
public A GetLeftOrElse(A defaultValue) => defaultValue;
public B GetRightOrElse(B defaultValue) => value;
public C Either<C>(Func<A, C> f, Func<B, C> g) where C : notnull => g(value);
}
(Obviously, a real-world implementation would implement Serialization and Deconstruction / Pattern Matching, etc.)
Unfortunately, the Either
type does not have the nice monadic properties of Option
. (Unless you "bias" it to one side, that is.) So, we can't implement IEnumerable
, Select
, SelectMany
, ForEach
, and friends. We have to either implement everything twice, or always pass both alternatives. So, for example instead of ForEach
we would have ExecuteIfLeft(Action<A>)
and ExecuteIfRight(Action<B>)
, or ExecuteEither(Action<A>, Action<B>)
, and similar for Select
, and so on.
This is essentially a Discriminated Union implemented in a language that doesn't support them.
Error
types
Halfway in between Option
(which only supports one type or the absence of a value) and Either
(which completely generically supports two types) lies the Error
type, which supports either a value or an error. You can think of it as a biased Either
, where one type is fixed to be an error type (type Error[A] = Either[A, Throwable]
). Because it is biased to one side, it satisfies all the monadic properties and can implement the LINQ Standard Query Operators (meaning Select
, SelectMany
, ForEach
, etc. all prefer one side).
The semantics are similar to Option
: it either returns the result of the whole computation chain, or it returns the first error and aborts the chain.
import scala.util.control.NonFatal
sealed trait Try[+A] {
val isSuccess: Boolean
val isFailure: Boolean
def getOrElse[B >: A](default: => B): B
def foreach[B](f: A => B): Unit
def map[B](f: A => B): Try[B]
def flatMap[B](f: A => Try[B]): Try[B]
}
final case class Success[+A](value: A) extends Try[A] {
override final val isSuccess = true
override final val isFailure = false
override def getOrElse[B >: A](default: => B): value.type = value
override def foreach[B](f: A => B) = f(value)
override def map[B](f: A => B): Success[B] = Success(f(value))
override def flatMap[B](f: A => Try[B]) = try f(value) catch { case NonFatal(e) => Failure(e) }
}
final case class Failure[+A, E <: Throwable](ex: E) extends Try[A] {
override final val isSuccess = false
override final val isFailure = true
override def getOrElse[B](default: => B) = default
override def foreach[B](f: A => B) = ()
override def map[B](f: A => B) = this.asInstanceOf[Try[B]]
override def flatMap[B](f: A => Try[B]) = this.asInstanceOf[Try[B]]
}
object Try {
def apply[A](f: => A): Try[A] =
try Success(f) catch {
case NonFatal(e) => Failure(e)
}
}
Scastie link
And again in C#
interface Try<T> where T : notnull
{
bool IsSuccess { get; }
bool IsFailure { get; }
T GetOrElse(T defaultValue);
void ForEach(Action<T> action);
Try<U> Select<U>(Func<T, U> f) where U : notnull;
}
readonly record struct Success<T>(T value) : Try<T> where T : notnull
{
public bool IsSuccess { get => true; }
public bool IsFailure { get => false; }
public T GetOrElse(T defaultValue) => value;
public void ForEach(Action<T> action) => action(value);
public Try<U> Select<U>(Func<T, U> f) where U : notnull => new Success<U>(f(value));
}
readonly record struct Failure<T, E>(E ex) : Try<T> where T : notnull where E : Exception
{
public bool IsSuccess { get => false; }
public bool IsFailure { get => true; }
public T GetOrElse(T defaultValue) => defaultValue;
public void ForEach(Action<T> action) { }
public Try<U> Select<U>(Func<T, U> f) where U : notnull => this as Try<U>;
}
I believe between these three examples, we have covered all of your use cases (two different types, a type or an error) as well as another common case (value that might exist or not).
In all three cases, we can use pattern matching, as you suggested. But actually, especially for Option
and Try
, it is even better to treat them either as collections (using ForEach
, Select
, SelectMany
, Where
, and so on) or as monads (in LINQ Query Expressions), because they just do the right thing by themselves without the need for any sort of conditional (whether that be an if
statement or pattern matching).
All three of these are essentially your proposal #2, while also facilitating #3, but with well-known names and reusable, general semantics. In fact, many languages ship these three types in their standard libraries, e.g. Scala: Option
, Either
, Try
. Even Java now ships with Optional
, although unfortunately, this one does not implement the collection API.