It Seems the C# Team Is Finally Considering Supporting Discriminated Unions

Cesar Aguirre - Sep 9 - - Dev Community

I originally posted this post on my blog a long time ago in a galaxy far, far away.


C# is getting more and more functional with every release.

I don't mean functional in the sense of being practical or not. I mean C# is borrowing features from functional languages, like records from F#, while staying a multi-paradigm language.

Yes, C# will never be a fully functional language. And that's by design.

But, it still misses one key feature from functional languages: Discriminated Unions.

Discriminated Unions Are a Closed Hierarchy of Classes

Think of discriminated unions like enums where each member could be an object of a different, but somehow related, type.

Let me show you an example where a discriminated union makes sense:

At a past job, while working with a reservation management software, hotels wanted to charge a deposit before the guests arrived. They wanted to charge some nights, a fixed amount, or a percentage of room charges, right after getting the reservation or before the arrival date.

Here's how to represent that requirement with a discriminated union:

public record Deposit(Charge Charge, Delay Delay);

// Of course, I'm making this up...
// This is invalid syntax
public union record Charge // 👈
{
    NightCount Nights;
    FixedAmount Amount;
    Percentage Percentage;
}

public record NightCount(int Count);
public record FixedAmount(decimal Amount, CurrencyCode Currency);
public enum CurrencyCode
{
    USD,
    Euro,
    MXN,
    COP
}
public record Percentage(decimal Amount);

public record Delay(int Days, DelayType DelayType);
public enum DelayType
{
    BeforeCheckin,
    AfterReservation
}
Enter fullscreen mode Exit fullscreen mode

Here, the Charge class would be a discriminated union. It could only hold one of three values: NightCount, FixedAmount, or Percentage.

You might be thinking it looks like a regular hierarchy of classes. And that's right.

But, the missing piece is that discriminated unions are exhaustive. We don't need a default case when using a discriminated union inside a switch. And, if we add a new member to the discriminated union, the compiler will warn us about where we should handle the new member.

Discriminated unions are helpful to express restrictions, constraints, and business rules when working with Domain Driven Design. In fact, using types to represent business rules is the main idea of the book Domain Modeling Made Functional.

For example, discriminated unions are a good alternative when moving I/O to the edges of our apps and just returning decisions from our domains.

I told you that C# is borrowing some new features from functional languages. Well, if you're curious, this is how our example will look like in F# using real discriminated unions:

type Deposit = {
    Charge: Charge;
    Delay: Delay;
}

type Charge = // 👈 Look, ma! A discriminated union
    | NightCount of int
    | FixedAmount of decimal * CurrencyCode
    | Percentage of decimal

type CurrencyCode =
    | USD
    | Euro
    | MXN
    | COP

type Delay =
    | BeforeCheckin of int
    | AfterReservation of int
Enter fullscreen mode Exit fullscreen mode

All credits to Phind, "an intelligent answer engine for developers," for writing that F# example.

A Proposal for a Union Keyword

It seems the C# language team is considering fully supporting discriminated unions.

In the official C# GitHub repository, there's a recent proposal for discriminated unions. The goal is to create a new type to "store one of a limited number of other types in the same place" and let C# do all the heavy work to handle variables of that new type, including checking for exhaustiveness.

The proposal suggests introducing a new union type for classes and structs. Our example using the union type will look like this:

public union Charge
//     👆👆
{
    NightCount(int Count);
    FixedAmount(decimal Amount, CurrencyCode Currency);
    Percentage(decimal Amount);
}

// This is how to instantiate a union type
Charge chargeOneNight = new NightCount(1); // 👈
var oneNightBeforeCheckin = new Deposit(
        chargeOneNight
        //  👆👆👆
        new Delay(1, DelayType.BeforeCheckin));

// This is how to use it inside a switch
var amountToCharge = charge switch {
    NightCount n => DoSomethingHere(n),
    FixedAmount a => DoSomethingElseHere(a),
    Percentage p => DoSomethingDifferentHere(p)
    // No need to declare a default case here...
}
Enter fullscreen mode Exit fullscreen mode

The new union type will support pattern matching and deconstruction too.

Under the hood, the union type will get translated to a hierarchy of classes, with the base class annotated with a new [Closed] attribute. And, if the default union type doesn't meet our needs, we can use that new attribute directly.

One Alternative While We Wait

The union type is still under discussion. We'll have to wait.

In the meantime, we can emulate this behavior using third-party libraries like OneOf.

Here's how to define our Charge type using OneOf:

public class Charge : OneOfBase<NightCount, FixedAmount, Percentage>
//                    👆👆👆
{
    public Charge(OneOf<NightCount, FixedAmount, Percentage> input)
        : base(input)
    {
    }
}
// Or using OneOf Source Generation:
//
//[GenerateOneOf] // 👈
//public partial class Charge : OneOfBase<NightCount, FixedAmount, Percentage>
//                              👆👆👆
//{
//}

public record NightCount(int Count); // 👈 No base class here
public record FixedAmount(decimal Amount, CurrencyCode Currency);
public record Percentage(decimal Amount);

// Here's how to instantiate a OneOf type
var oneNightBeforeCheckin = new Deposit(
        new Charge(new NightCount(1)), // 👈
        new Delay(1, DelayType.BeforeCheckin));
Enter fullscreen mode Exit fullscreen mode

OneOf brings methods like Match, Value, and AsT0/AsT1/AsT2 to work with and unwrap the underlying type.

Voilà! That's what discriminated unions are, and the official proposal to bring them to the C# language.

You see, C# is getting more functional on each release. While we wait to get it more funcy with discriminated unions, we have to go with libraries and workarounds. Let's wait to see how it goes.


Starting out or already on the software engineering journey? Join my free 7-day email course to refactor your coding career and save years and thousands of dollars' worth of career mistakes.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .