In this article, we will look at the usage of discriminated unions in C#, specifically with the open-source library OneOf. We’ll go into some practical examples and why this pattern is becoming increasingly popular in .NET applications today.

To download the source code for this article, you can visit our GitHub repository.

First, let’s talk about why we need discriminated unions in the first place.

Why Do We Need Discriminated Unions?

Before we dive into discriminated unions, it will be helpful to understand why it’s needed. So let’s look at a simple example without discriminated unions to demonstrate the problem, which we can return to later in the article to see how discriminated unions can help.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Setting up a Simple API

Let’s create a new ASP.NET Core Web API project in Visual Studio, and add a few records:

public record Receipt(int ReceiptId, int Payment);

public record Product(int ProductId, string Name, int Cost);

public record Order(int ProductId, int Payment);

We are going to use a common example of an orders service, where we can place orders and get receipts back.

So, to continue, let’s create the IOrdersService interface:

public interface IOrdersService
{
    Receipt PlaceOrder(Order order);
}

Next, we are going to implement it with a new class OrdersService:

public class OrdersService : IOrdersService
{
    private List<Product> _products;
    private List<Receipt> _receipts;
    private int _receiptId;

    public OrdersService()
    {
        _products = new List<Product>
        {
            new Product(1, "Keyboard", 80),
            new Product(2, "Mouse", 50),
            new Product(3, "Monitor", 500)
        };

        _receipts = new List<Receipt>();
    }      

    public Receipt PlaceOrder(Order order)
    {
        var product = _products.SingleOrDefault(p => p.ProductId == order.ProductId);
        
        if (product is null)
        {
            throw new Exception("Product doesn't exist");
        }

        if (product.Cost > order.Payment)
        {
            throw new Exception("Insufficient funds");
        }

        var receipt = new Receipt(++_receiptId, order.Payment);
        _receipts.Add(receipt);

        return receipt;
    }
}

In this class, we first create some products with a few basic properties. We then initialize a receipts list and create a method containing the main logic. Notice the return type is Receipt.

Now let’s wire up the service in Program.cs:

builder.Services.AddSingleton<IOrdersService, OrdersService>();

Finally, let’s replace the WeatherController with an OrdersController:

[ApiController]
[Route("[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrdersService _ordersService;

    public OrdersController(IOrdersService ordersService)
    {
        _ordersService = ordersService;
    }

    [HttpPost]
    public IActionResult Post(Order order)
    {
        var receipt = _ordersService.PlaceOrder(order);

        return Ok(receipt);
    }
}

Let’s make sure this is working by running the application and performing a POST request in Postman:

Discriminated Unions Sample API POST

As we expect, the API executes correctly and returns a 200 (OK), with a receipt.

Problems

So what are the problems with this code? The problem is that the Post() method has no idea that there are actually 3 possible outputs of the PlaceOrder() method:

  • Exception, when the product doesn’t exist
  • Exception, when the cost of the product exceeds the payment provided
  • Receipt, when the order is valid

So let’s see what happens if we modify the POST request and specify a productId that doesn’t exist:

Product doesn't exist

The API crashes, and whatever is consuming this API would crash too. In other words, the API / consumer code had no idea of the implementation of the place order method. All it has is an interface specifying a return type of Receipt, so that’s all it handled.

This is a very common problem, and the way we handle this is generally by looking at the source code implementation (which isn’t always possible, think closed-source libraries), and using a try/catch block to handle exceptions. 

This approach is not very transparent, and it doesn’t create a strong contract between the method and the caller. 

This is where discriminated unions come in, so let’s unpack that in the next chapter.

What Are Discriminated Unions?

Discriminated Unions are not a new concept. In fact, it has been in functional programming languages such as Haskell and F# for quite some time but has been missing in C#. The fundamental concept behind discriminated unions is to ensure compile-time assurances that a method can yield any of several distinct types within a defined set.

In our previous example, we discovered that our method can return 2 different return types (Exception and Receipt), and with 3 different scenarios. With discriminated unions, we can make this explicit to the caller.

Microsoft is currently in design discussions about adding discriminated unions to C#. It could be a while before that feature is added (if ever). Therefore in the meantime, our best way to get this feature is to use the excellent C# library called OneOf.

Discriminated Unions With OneOf

Let’s add the OneOf package to our project:

PM> Install-Package OneOf

A great thing about this package is that it runs on most versions of .NET, and has zero dependencies.

Let’s first create an enum to hold various errors:

public enum PlaceOrderError
{
    DoesntExist,
    InsufficientFunds
}

Now let’s change our method signature for the PlaceOrder() method:

public OneOf<Receipt, PlaceOrderError> PlaceOrder(int productId, int payment)

We now explicitly define that the output of the method has to be “one of” (hence the name) Receipt, and PlaceOrderError.

Next, let’s swap out the exceptions for our new enum:

if (product is null)
{
    return PlaceOrderError.DoesntExist;
}

if (product.Cost > order.Payment)
{
    return PlaceOrderError.InsufficientFunds;
}

Let’s also change our interface:

using OneOf;

public interface IOrdersService
{
    OneOf<Receipt, PlaceOrderError> PlaceOrder(Order order);
}

Discriminated Unions vs Tuples

It’s important not to confuse discriminated unions with another C# feature “tuples”, which would look like this:

public (Receipt receipt, PlaceOrderError error) PlaceOrder(int productId, int payment)

While it looks similar, it’s actually very different. The tuple example means the method needs to return both a Receipt and an PlaceOrderError, which of course makes no sense. In other words, if we want to return many of multiple return types, use tuples; if we want to return one of multiple return types, use discriminated unions.

Now that we’ve updated our method signature, let’s update our calling code to handle it.

Method Returns With Discriminated Unions

If we return to our controller, we notice everything is still compiling. However, the type of receipt is now of type OneOf<Receipt, PlaceOrderError> as we expect. So we now need to handle these two response types. 

We can do this via the Match() method in OneOf:

var placeOrderResult = _ordersService.PlaceOrder(order);

return placeOrderResult.Match<IActionResult>(
    receipt => Ok(receipt),
    error => StatusCode(500, new { error = error.ToString() }));

The Match() method takes a fixed number of delegates, that need to match the number of possible outputs from the PlaceOrder() method. In this case, we are handling the Receipt type and the PlaceOrderError result type.

In this example, we are returning an Ok with the receipt model, or a 500 with an error in the payload. The key takeaway is the calling code is now fully aware of the possible return types and can take ownership of handling the method. 

Let’s run the failing request again in Postman:

Product doesn't exist - fixed

Now we have the proper handling of the failed scenario because the contract was defined.

In our Post() method we are wanting to return two different result types, but if we didn’t want to return anything, we could use the Switch() statement instead:

placeOrderResult.Switch(
    receipt => DoSomethingWithReceipt(receipt),
    error => HandleError(error)
);

But that doesn’t make a lot of sense for this particular API endpoint, so let’s leave our code with Match().

The OneOf library also has a number of built-in types to make it easier for us, for example, if we had a method that either did something or didn’t do it, we could declare it as:

public OneOf<Yes, No> DoSomething()

We could of course use a bool to achieve the same outcome, but again it’s about being transparent and explicit about the behavior. There are a lot more built-in types, and we encourage you to explore them.

Let’s move on to discriminated unions in method parameters.

Method Parameters With Discriminated Unions

Just like we can control the method returns, we can also control the method parameters.

Let’s add a new method to our OrdersService:

public OneOf<Product, OneOf.Types.NotFound> FindProduct(OneOf<string, int> productNameOrId)
{
    Product? product;

    if (productNameOrId.IsT0)
    {
        product = _products.SingleOrDefault(product => product.Name.Equals(productNameOrId.AsT0));
    }
    else
    {
        product = _products.SingleOrDefault(product => product.ProductId == productNameOrId.AsT1);
    }

    return product is null
        ? new OneOf.Types.NotFound()
        : product;
}

Here we create the ability for the caller to find a product by either name or id, with a single method parameter. We could of course split these parameters out, but as the number of parameters grows, this method could quickly become unmanageable. Because they are both relevant to the filter, it makes sense to group them together.

Note the use of the IsT0, AsT0, and AsT1 properties. Like Tuples, these are convenience properties that point to the item in OneOf property based on the index. In this case T0 is the product name, and T1 is the ID.

We’re also making use of a OneOf built-in type, called NotFound. This makes it very easy to express that the product was not found, so it can be handled in the controller.

Let’s add it to our interface:

OneOf<Product, NotFound> FindProduct(OneOf<string, int> productNameOrId);

We can then add a couple of endpoints to expose the logic:

[HttpGet("findProductById")]
public IActionResult GetProductById(int id)
{
    var getProductResult = _ordersService.FindProduct(id);

    return getProductResult.Match<IActionResult>(
        product => Ok(product),
        notFound => NotFound());
}

[HttpGet("findProductByName")]
public IActionResult GetProductByName(string name)
{
    var getProductResult = _ordersService.FindProduct(name);

    return getProductResult.Match<IActionResult>(
        product => Ok(product),
        notFound => NotFound());
}

This is great because again we are being very explicit about the method parameters, which leads to code that is very easy to understand and maintain.

Why Discriminated Unions Are Important in APIs

Our example so far is very trivial, but consider if we added more logic to our Post() method. For example some more validation and authorization. We would then likely have the multiple response codes:

  • Success / 200 (order placed)
  • Bad input / 400 (incorrect product Id or name)
  • Unauthorized / 401 (don’t know who the user is placing the order)
  • Conflict / 403 (insufficient funds to place the order)
  • Not found / 404 (product doesn’t exist)

Handling all of these scenarios without the OneOf library would likely mean a combination of try/catch blocks, global error handlers, and validation middleware. While there is nothing wrong with these approaches, it makes it a little harder to easily see where all this logic is handled. This is due to the fact it exists in various places.

By placing the handling of the code as close to the execution as possible, it means we increase the visibility of the logic and keep the logic where it belongs. This is why APIs are a great use case for discriminated unions.

Conclusion

In this article, we looked at how discriminated unions help address the visibility and maintenance problems of different return types and method parameters. While we wait for the possibility of this feature being introduced natively in C#, we can leverage the OneOf library in the meantime to unlock all these advantages. We highly encourage you to go out and try it.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!