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
Top comments (5)
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.
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?
Can types specified in the OneOf be non related? Because if the all implement the same interface this can be done using standard generics...
What about that Behavior Trees articles? ;)
Thanks for the reminder. I believe I started that at one point, but a turn based behavior tree was underwhelming.