This post was part of C# Advent Calendar 2023 run by @mgroves.

Native Discriminated Unions in C# are a hot-topic for many in the Community. They have been a core F# feature from version 1.0 in 2005 and are used for many things in F# codebases including:

  • Handling Nulls and Missing Values
  • Handling Business Errors
  • Domain Modelling

There was a discussion document released earlier this year by the C# Language Team about some of the possible options for making Discriminated Unions a C# feature in an upcoming release of the language. A video about their discussions can be viewed here:

Languages & Runtime Community Standup - Considering discriminated unions

So, folks want them and Microsoft is discussing how to add them to C# but what are they?

Discriminated Unions are:

  • Types where instances represent a single case from a closed set of possible cases.
  • Each case may carry along additional data.

To illustrate the concept more clearly, we’ll look at some simple examples in F#. Don’t worry, the examples are very simple.

A boolean can either be true or false. We could define it as a Discriminated Union like this:

type Boolean = True | False

The Boolean type cases can also be written on multiple lines, like this:

type Boolean = 
    | True
    | False

To use the Boolean type, we would do the following:

let iamTrue = True // Boolean
let iamFalse = False // Boolean

It’s important to note that True and False are not types in their own right - They are cases of Boolean, with no additional data attached.

How would you define a type in C# that expresses a filter for a query where it can represent an Id of Guid, a Name of string, or an Email of string? Discriminated Unions support that:

type UserKey =
    | Id of Guid
    | Name of string
    | Email of string

Each UserKey case has an identifier and some data attached to it.

To create an instance of a UserKey, we can do this:

let nameKey = Name "Fred" // UserKey
let emailKey = Email "ian@fsharpisawesome.universe" // UserKey

You can think of the case identifier as a constructor but you must remember that it creates the containing type, not an instance of the case.

To use the Discriminated Union, we pass around an instance of the type and use Pattern Matching to determine the case and its data:

// UserKey -> option<User> [Equivalent to Func<UserKey, User?> in C#]
let tryFindUser(key:UserKey) : User option =
    match key with
    | Id id -> ...
    | Name name -> ... 
    | Email email -> ...

An F# match expression is very similar to a C# switch expression, except that Discriminated Unions in F# are a closed set, so the compiler knows when all cases have been covered and warns you if they have not.

There are a couple of built-in Discriminated Unions in the F# Language for Optional values, which we use instead of nullable types, and Results to support happy and unhappy paths instead of handling business errors using Exceptions:

// In FSharp.Core
type Option<'T> =
    | Some of 'T
    | None
    
// In FSharp.Core
type Result<'TSuccess, 'TFailure> =
    | Ok of 'TSuccess
    | Error of 'TFailure

If you’re interested in some C# implementations of Option and Result, watch the following videos:

Now that we have an understanding of what a Discriminated Union is, let’s introduce a business scenario that could benefit from using them in C#.

The Scenario

We will look at how to model applying a discount to an order for some customers. This is the feature:

Feature: Applying a discount
Scenario: Eligible Registered Customers get 10% discount when they spend £100 or more

Given the following Registered Customers
|Customer Id|Is Eligible|
|John       |true       |
|Mary       |true       |
|Richard    |false      |

When [Customer Id] spends [Spend]
Then their order total after applying [Discount] will be [Total]

No Discount Applied Examples:
|Customer Id|   Spend|   Total|Reason        |
|Mary       |   99.00|   99.00|Spend too low |
|Richard    |  100.00|  100.00|Not Eligible  |
|Sarah      |  100.00|  100.00|Not Registered|

Discount Applied Examples:
|Customer Id|   Spend|   Total|
|John       |  100.00|   90.00|

Notes:
Only Registered Customers can be Eligible

In the following code examples, we are going to concentrate on the types of customer - Eligible, Registered, Unregistered - that we need to model.

Getting Started

All of the code examples were written in LINQPad 8. You can copy the code and run them as C# Programs.

Example 1 - Using a Simple Record Type

We are going to start by creating a simple record for the Customer which will have three properties: Id, IsRegistered, and IsEligible:

void Main()
{
    Customer john = new Customer("John", true, true);
    Customer mary = new Customer("Mary", true, true);
    Customer richard = new Customer("Richard", true, false);
    Customer sarah = new Customer("Sarah", false, false);

    (CalculateTotal(john, 100m) == 90m).Dump("john");
    (CalculateTotal(mary, 99m) == 99m).Dump("mary");
    (CalculateTotal(richard, 100m) == 100m).Dump("richard");
    (CalculateTotal(sarah, 100m) == 100m).Dump("sarah");
}

// You can define other methods, fields, classes and namespaces here
public record Customer(string Id, bool IsRegistered, bool IsEligible);

public decimal CalculateTotal(Customer customer, decimal spend)
{
    var discount = customer.IsRegistered && customer.IsEligible && spend >= 100m ? spend * 0.1m : 0m;

    return spend - discount;
}

This works fine and you’ll see examples similar to this in most C# codebases. I see two problems with this simple example:

  • It is possible to create a Customer in an invalid state.
  • It has implicit domain knowledge wrapped up in two boolean properties.

To create an invalid Customer, we can do this:

Customer invalidCustomer = new Customer("BadIan", IsRegistered: false, IsEligible: true);

To solve this issue, we could add a private constructor and use Smart Constructors to allow only the creation of valid Customers but it doesn’t solve the implicit knowledge problem. It requires intimate knowledge of the Customer type to determine what it means to be an Eligible Customer.

Example 2 - Using Inheritance with Type Test and Set

The first improvement we are going to make is to convert the Customer into an empty abstract record and add derived classes for Registered and Guest:

void Main()
{
    Customer john = new Registered("John", true);
    Customer mary = new Registered("Mary", true);
    Customer richard = new Registered("Richard", false);
    Customer sarah = new Guest("Sarah");

    (CalculateTotal(john, 100m) == 90m).Dump("john");
    (CalculateTotal(mary, 99m) == 99m).Dump("mary");
    (CalculateTotal(richard, 100m) == 100m).Dump("richard");
    (CalculateTotal(sarah, 100m) == 100m).Dump("sarah");
}

// You can define other methods, fields, classes and namespaces here
public abstract record Customer;

public sealed record Registered(string Id, bool IsEligible) : Customer;
public sealed record Guest(string Name) : Customer;

public decimal CalculateTotal(Customer customer, decimal spend)
{
    var discount = customer switch
    {
        Registered c when c.IsEligible && spend >= 100m => spend * 0.1m,
        Registered _ => 0m,
        Guest _ => 0m,
        _ => throw new InvalidOperationException()
    };
    return spend - discount;
}

The key takeaway from this example is that we always create a Customer against the base type, even though we are not sharing any behaviour. We are using inheritance because it is the only mechanism to build these structures available to us in C#.

We use Type Test and Set in the Pattern Match to help determine the discount to apply to the order for the specific customer passed into the CalculateTotal function. I know that some folks won’t like this as it breaks OOP and SOLID but this is what Type Test and Set was added for.

This set of records is our first simulated Discriminated Union. It isn’t a closed set, so I can add a new derived record anywhere in the codebase if I wanted to. Notice also that the switch expression requires a wildcard as the compiler has no concept of what a closed set is.

Example 3 - Preventing Rogue Derived Classes

In this iteration, we will try to prevent folks adding new derived record types elsewhere in the codebase.

void Main()
{
    Customer john = new Customer.Registered("John", true);
    Customer mary = new Customer.Registered("Mary", true);
    Customer richard = new Customer.Registered("Richard", false);
    Customer sarah = new Customer.Guest("Sarah");

    (CalculateTotal(john, 100m) == 90m).Dump("john");
    (CalculateTotal(mary, 99m) == 99m).Dump("mary");
    (CalculateTotal(richard, 100m) == 100m).Dump("richard");
    (CalculateTotal(sarah, 100m) == 100m).Dump("sarah");
}

public abstract record Customer
{
    private Customer() {}
    
    public sealed record Registered(string Id, bool IsEligible) : Customer;
    public sealed record Guest(string Id) : Customer;
}

public decimal CalculateTotal(Customer customer, decimal spend)
{
    var discount = customer switch
    {
        Customer.Registered c when c.IsEligible && spend >= 100m => spend * 0.1m,
        Customer.Registered _ => 0m,
        Customer.Guest _ => 0m,
        _ => throw new InvalidOperationException()
    };
    return spend - discount;
}

Adding the scoping and the private constructor prevents anyone from creating a new derived record outside the Customer scope.

Example 4 - Adding an Eligible Class

The final iteration will be to add an explicit record for Eligible customers.

void Main()
{
    Customer john = new Customer.Eligible("John");
    Customer mary = new Customer.Eligible("Mary");
    Customer richard = new Customer.Registered("Richard");
    Customer sarah = new Customer.Guest("Sarah");

    (CalculateTotal(john, 100m) == 90m).Dump("john");
    (CalculateTotal(mary, 99m) == 99m).Dump("mary");
    (CalculateTotal(richard, 100m) == 100m).Dump("richard");
    (CalculateTotal(sarah, 100m) == 100m).Dump("sarah");
}

// You can define other methods, fields, classes and namespaces here
public abstract record Customer
{
    private Customer() {}

    public sealed record Eligible(string Id) : Customer;
    public sealed record Registered(string Id) : Customer;
    public sealed record Guest(string Id) : Customer;
}

public decimal CalculateTotal(Customer customer, decimal spend)
{
    var discount = customer switch
    {
        Customer.Eligible c when spend >= 100m => spend * 0.1m,
        _ => 0m
    };
    return spend - discount;
}

This has simplified the logic inside the CalculateTotal function.

Conclusions

As you have seen, it is possible to simulate Discriminated Unions in C# and to gain some of their benefits. None of the examples is perfect but I think that they are perfectly useable. The key benefit of taking this approach in domain modelling is to:

Make implicit domain knowledge explicit.

Making specific types for different parts of your system is really easy. You don’t need to rely on one big Entity with hidden domain knowledge inside.

Microsoft are actively discussing introducing Discriminated Unions for C# at some point in the (near) future. It is likely that their only viable solution will be to add further syntactic sugar like they did for records, so converting from the examples in this post should be relatively simple.

What About F#?

F# has extensive built-in support for Discriminated Unions, so this sort of domain modelling is trivial:

type RegisteredCustomer = { Id: string }
type UnregisteredCustomer = { Name: string }
type Spend = decimal
type Total = decimal

type Customer =
    | Eligible of RegisteredCustomer
    | Registered of RegisteredCustomer
    | Guest of UnregisteredCustomer

// Func<Customer, Spend, Total>
let calculateTotal(customer:Customer, spend:Spend) : Total =
    let discount = 
        match customer with
        | Eligible _ when spend >= 100.0M -> spend * 0.1M
        | _ -> 0.0M
    spend - discount

let john = Eligible { Id = "John" } // Customer
let mary = Eligible { Id = "Mary" } // Customer
let richard = Registered { Id = "Richard" } // Customer
let sarah = Guest { Name = "Sarah" } // Customer

(calculateTotal(john, 100.0M) = 90.0M).Dump("john")
(calculateTotal(mary, 99.0M) = 99.0M).Dump("mary")
(calculateTotal(richard, 100.0M) = 100.0M).Dump("richard")
(calculateTotal(sarah, 100.0M) = 100.0M).Dump("sarah")

This only shows a tiny fraction of the power of F#, Discriminated Unions, and Pattern Matching. They are used extensively in most F# codebases, especially the Line of Business applications/services that most of us write every day.

If you want to learn more about using F# to create succinct, robust, and performant code, download my free 200-page ebook: Essential F#. It’s full of practical examples like the scenario in this post and is designed to get folks up-and-running in functional-first programming in F# very quickly.

WARNING: Learning F# may lead you to think less favourably of C# than you currently do.

Summary

In this post, you have discovered what Discriminated Unions are, what is and isn’t currently possible when implementing them in C# 12, and more importantly, why you might want to consider using the general concept in your codebases to help make implicit domain knowledge explicit.

If you want to learn more about Functional Programing with C#, Simon J. Painter’s book is a great place to start. His C# Advent Calendar 2023 entry is available here.