loading...
Cover image for OneOf Discriminated Unions in C#

OneOf Discriminated Unions in C#

integerman profile image Matt Eland Originally published at killalldefects.com Updated on ・5 min read

Ever wish you could act on different types of variables - effectively switching by object type and taking different action depending on which class is present? Discriminated Unions from functional programming languages offer an answer to this. In this article I explore the good and bad of Discriminated Unions in C# and offer some thoughts on whether this might be right for your project.

Discriminated Unions are a functional programming convenience that indicates that something is one of several different types of objects. For example, a User might be an unauthenticated user, a regular user, or an administrator.

While discriminated unions are being evaluated for addition to the C# language, they are not presently available, however, the OneOf library provides an alternative for those wanting to use discriminated unions before we have official language support for it.

Let me show you how it works and why you may or may not want to use it.

How OneOf Works

Returning a OneOf Result

This example is from a hobbyist game development project I'm tinkering with. I needed a routine that has a technician attempt to work on a work item. Depending on a random number result, they will either get a WorkCompletedResult, a ProgressMadeResult, or a SetbackResult.

Note: I ask that you suspend your disbelief as to whether or not these classes should even exist and look at this as purely a technical demonstration of OneOf

private OneOf<WorkCompletedResult, ProgressMadeResult, SetbackResult> 
        GetWorkOnItemResult(CrewContext context, WorkItem item)
{
    var roll = context.GetRandomNumber(1, 20);

    // Something went wrong. Likely a mistaken assumption or bad requirement
    if (roll == 1)
    {
        return new SetbackResult(Priority.High);
    }

    // We got a massive success or we've previously made significant progress
    if (roll >= 20 - item.ProgressMade)
    {
        return new WorkCompletedResult(item);
    }

    // Okay, not a great roll, let's call it a setback
    if (roll < 3)
    {
        return new SetbackResult(Priority.Normal);
    }

    // Just a bit of progress
    return new ProgressMadeResult(item);
}

Let's unpack this. The only really unusual part about this code is the method's return type: OneOf<WorkCompletedResult, ProgressMadeResult, SetbackResult>.

This is, frankly, a fairly ugly way of using generics to say that the result of the method will be one of the different types of generic arguments.

From there, as long as the method returns one of these results no code changes are needed.

Working with OneOf Results

So, now that we have a method returning a OneOf result, what can we do with this?

The following example is from a node in a behavior tree I'm building (stay tuned for a future article on more details on behavior trees in general). This code switches off of the result of the call too the GetWorkOnItemResult method defined above and handles each case differently.

private BehaviorResult<CrewContext> ProcessCrewWorkOnItem(CrewContext context, WorkItem ticket)
{
    var result = GetWorkOnItemResult(context, ticket);

    result.Switch(
        completed =>
        {
            ticket.Status = WorkItemStatus.ReadyForReview;
            AddMessage(context, $"{context.CrewMember.FullName} finishes work on {ticket.Title}");
        },
        progress =>
        {
            ticket.ProgressMade++;
            AddMessage(context, $"{context.CrewMember.FullName} made steady progress on {ticket.Title}");
        },
        setback =>
        {
            if (setback.Severity > Priority.Normal)
            {
                ticket.ProgressMade -= 3;
                AddMessage(context, $"{context.CrewMember.FullName} encountered a major setback on {ticket.Title}");
            }
            else
            {
                ticket.ProgressMade -= 1;
                AddMessage(context, $"{context.CrewMember.FullName} encountered a setback on {ticket.Title}");
            }
        }
    );

    return MatchedResult(context);
}

The .Switch function takes an Action parameter for each generic type defined, giving you access to a strongly-typed instance of that object type as we see with the use of the setback parameter, for example.

These arguments are matched in order to the order that the generic types are defined on the OneOf type declaration. In our example, the first parameter will be an Action<WorkCompletedResult>, the second will be an Action<ProgressMadeResult>, and the third will be Action<SetbackResult>. This is why we can use setback.ProgressMade, a property declared on the SetbackResult instance.

So, OneOf lets us return one of several different options and then gives you a method to do something different depending on which one of those types you encounter.

If you needed to structure your code so that each case returns a value, you can use Match instead of Switch as follows:

private BehaviorResult<CrewContext> ProcessCrewWorkOnItem(CrewContext context, WorkItem ticket)
{
    var result = GetWorkOnItemResult(context, ticket);

    var message = result.Match(
        completed =>
        {
            ticket.Status = WorkItemStatus.ReadyForReview;
            return $"{context.CrewMember.FullName} finishes work on {ticket.Title}";
        },
        progress =>
        {
            ticket.ProgressMade++;
            return $"{context.CrewMember.FullName} made steady progress on {ticket.Title}";
        },
        setback =>
        {
            if (setback.Severity > Priority.Normal)
            {
                ticket.ProgressMade -= 3;
                return $"{context.CrewMember.FullName} encountered a major setback on {ticket.Title}";
            }
            else
            {
                ticket.ProgressMade -= 1;
                return $"{context.CrewMember.FullName} encountered a setback on {ticket.Title}";
            }
        });

    AddMessage(context, message);

    return MatchedResult(context);
}

As you can see, this is nearly identical to Switch except Match uses a Func<T, TResult> instead of an Action<T>.

Weak Areas

So, this is an interesting trick, but where does it fall down?

Type Aliases

Unlike languages like TypeScript and F#, you can't define a simple reusable alias for a Discriminated Union, meaning that if you pass around the same combinations of types, you have to use the same OneOf syntax on return types and parameter values and you can't use a simple type alias.

For reference: a type alias for a Discriminated Union in TypeScript looks like this:

type User = GuestUser | StandardUser | Administrator;

Code Completion

The code completion is very limited on the Switch and Map functions. The most irritating aspect is having to remember the ordering of types in the OneOf definition.

Order Swapping

If you declare two different OneOf return types with the parameters in different orders, they will not be swappable between each other

Acting only on a Specific Type

Let's say you want to look at a result and only do something if the return is a StetbackResult. You could either use Switch with empty parameters, or you can use the AsT1 and IfT1 members. Sadly, these are their actual names, so it becomes easy to mix up which type is in which order yet again.


My Opinion

So, this is something you can do in C#. The more important question is: Should you?

My answer to that, after investigating the library is: probably not. It's a cool trick, but the language constraints hamstring the viability of any library.

For now, unless you have some very targeted usages, my recommendation is that you avoid the added complexity of OneOf and either wait for official language support or add a small F# library and reference it from C# or VB .NET code.


If you have other thoughts or know of another way of getting Discriminated Unions work better in C#, please let me know.


Photo by Markus Spiske on Unsplash

Discussion

pic
Editor guide
Collapse
mdfrenchman profile image
Mike French

I’ve used dynamic as the return type or a base type. With sub classes, ex: GuestUser, Admin etc inherit from User.
Then checking for result as Admin or something other way to conditionally action by type.

Worked very well in my implementation but I also controlled knowing WHAT the method could potentially return.

Collapse
integerman profile image
Matt Eland Author

That's an interesting idea. You're sort of undercutting the framework and it could be a minor performance impact, but the code is likely better. Any samples you can share?

Collapse
peledzohar profile image
Zohar Peled

Can types specified in the OneOf be non related? Because if the all implement the same interface this can be done using standard generics...

Collapse
sgatner profile image
Szymon Gatner

What about that Behavior Trees articles? ;)

Collapse
integerman profile image
Matt Eland Author

Thanks for the reminder. I believe I started that at one point, but a turn based behavior tree was underwhelming.