OneOf - Discriminated Unions for c# - NPOTW 2 of N

This weeks package is one of my projects. I created it after working with F#'s discriminated union types.

What is a Discriminated Union Anyway

DU types are data structures used to hold a single value of one of a fixed set of Types e.g.:

type Shape =  
| Rectangle of width : float * length : float
| Circle of radius : float
| Prism of width : float * float * height : float

So a Shape instance will hold either a Rectange, Circle or Prism instance.

In F# you can use match statement to write a handler for each case, or to map a DU to a single type.

let getShapeHeight shape =  
    match shape with
    | Rectangle(height = h) -> h
    | Circle(radius = r) -> 2. * r
    | Prism(height = h) -> h

It's almost a bit like a switch statement on the type, or doing a long if else if else block, with a key difference.

The compiler enforces that you write a handler for each case. In c#, if you created a new Type class Square : Shape {}, and you have a switch statement for rendering, it will continue to compile. In F#, if you added a new type to the DU you will get a compile error.

This makes your code far more maintainable as you go forward. You can update a method in a library project with a new return value, and any consuming code elsewhere in your project will be forced to explicitly handle it, rather than falling back to a default implementation (often a runtime NotImplementedException).

Having DUs and the Match statement provide an alternative to adding an abstract property to a base class which is the only way to guarantee this level of compiler checking in a c# codebase.

Enter OneOf

install-package OneOf

GitHub

By using a custom struct OneOf<T0, ..., Tn> where you list the possible types as generic arguments we can get somewhat similar behaviour in c#. An instance of this type holds a single value, which is one of the types in its generic argument list.

OneOf types implicitly cast from values of their generic parameter types. e.g.

Rectangle rect = new Rectangle(10, 10, 10, 10);  
OneOf<Rectangle, Circle, Prism> shape = rect;

//You can then access the value using .IsT0 and .AsT0
Assert.True(shape.IsT0);  
Assert.True(shape.AsT0 is Rectangle);

which means they work very nicely as return values:

public OneOf<Product, NotFound> FindProduct(int productId)  
{
    var product = session.Products.SingleOrDefault(p => p.Id == productId)
    if (product == null) return new NotFound();
    return product;
}
Matching

You use the TOut Match(Func f0, ... Func fn) method to perform an action E.g.

   OneOf<Rectangle, Circle, Prism> shape = ...;
   shape.Match(
     rect => gfx.DrawRect(rect.X, rect.Y, rect.Width, rec.Height), 
     circle => gfx.DrawCircle(circle.X, circle.Y, circle.Radius),
     prism => ... 
   ); 
}

or Map to another value

   OneOf<Rectangle, Circle, Prism> shape = ...;
   var area = shape.Match(
     rect => rect.Width * rec.Height, 
     circle => 2 * Math.Pi * circle.Radius,
     prism => ... 
   ); 
}

If you then add another T to the generic argument list, you will have to add another lambda to the .Match statement to get it to compile.

Uses

There are a few things I find this very useful for:

  • Typically MVC and WebApi controller actions end up being typed as ActionResult, or object because they commonly return NotFound or ErrorResults, or a happy-path result value. Using OneOf allows you to make it explicit what the valid return types are. This is very handy if you are hoping to generate documentation or an SDK for your API.
  • Similarly you can use this to remove Exceptions As Control Flow by creating OneOf<ResultType, ErrorType1, ErrorType2, ...>
  • Reducing overloads when you want to let a caller pass several different types (see the code sample in matching above) e.g.
public void SetBackground(OneOf<string, ColorName, Color> backgroundColor) {  
   Color c = backgroundColor.Match(
     str => CssHelper.GetColorFromString(str),
     name => new Color(name),
     col => col
   );
   _window.BackgroundColor = c;
}

Similar libraries

I'm not the only one to have thought of this, there are many libraries on Nuget for accomplishing a similar feat.