In recent years, there has been a notable surge in inquiries from the C# community regarding language-level support for C# Discriminated Unions. What individuals truly desire is the ability to express complex data structures more elegantly and effectively.
This blog post offers a thorough introduction to simulating Discriminated Unions (DUs) in C# programs, highlighting their usefulness.
First, let’s delve into DUs in F#.
F# Discriminated Unions
Here is a F# program that illustrates DU:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
type Vehicle = | Car of model : string * year : int | Truck of model : string * year : int * payload : float | Bicycle of model : string let v1 = Car(model = "Ford Mustang", year = 1965) let v2 = Truck(model = "Nissan Titan", year = 2018, payload = 1930.0) let v3 = Bicycle(model = "Rock Rider") let getVehicleDesc (v: Vehicle) = match v with | Car(model, year) -> sprintf "Car: %s, Year: %d" model year | Truck(model, year, payload) -> sprintf "Truck: %s, Year: %d, Payload: %.2f" model year payload | Bicycle(model) -> sprintf "Bicycle: %s" model printfn "%s" (getVehicleDesc v1) printfn "%s" (getVehicleDesc v2) printfn "%s" (getVehicleDesc v3) |
This program prints:
1 2 3 |
Car: Ford Mustang, Year: 1965 Truck: Nissan Titan, Year: 2018, Payload: 1930,00 Bicycle: Rock Rider |
Vehicle
is a Discriminated Union type. At runtime, an instance of Vehicle
is a Car
, a Truck
or a Bicycle
. If one of these option type is not handled, the F# compiler emits a warning:
The F# compiler builds a .NET DLL that contains IL code. If we decompile the IL code generated we can see that inheritance is used to handle F# DUs and that some helper methods are generated:
Simulating Discriminated Unions in C#
In C# we have a nice support for ValueTuple which are AND types. A person can be modelized through a first name and a last name and a birth year. Here what we want is support for OR type, a Vehicle
being a Car
, or a Truck
or a Bicycle
.
The Naive Way
Let’s attempt to rewrite the F# program with inheritance, C# records, and C# Pattern Matching syntax this way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var v1 = new Car("Ford Mustang", 1965); var v2 = new Truck("Nissan Titan", 2018, 1930.0f); var v3 = new Bicycle("Rock Rider"); Console.WriteLine(GetVehicleDesc(v1)); Console.WriteLine(GetVehicleDesc(v2)); Console.WriteLine(GetVehicleDesc(v3)); static string GetVehicleDesc(Vehicle v) => // C# pattern matching syntax v switch { Car car => $"Car: {car.Model}, Year: {car.Year}", Truck truck => $"Truck: {truck.Model}, Year: {truck.Year}, Payload: {truck.Payload:F2}", Bicycle bicycle => $"Bicycle: {bicycle.Model}", _ => "Unknown Vehicle" }; // C# inheritance and records syntaxes record Vehicle; record Car(string Model, int Year) : Vehicle; record Truck(string Model, int Year, float Payload) : Vehicle; record Bicycle(string Model) : Vehicle; |
Not too bad. This C# program is nearly as concise as its F# equivalent. But there are two important disadvantages:
- In F#, it is unlikely that more option types like
Plane
would be added toVehicle
. Because theVehicle
type definition would be modified. This would cause compiler warnings or errors everywhere it is used. We say thatVehicle
in F# is closed for modification. On the other hand, in C# we can append moreVehicle
derived record classes without touching theVehicle
definition nor breaking any code. We say thatVehicle
in C# is open for extension. The Open Close Principle (OCP) is an advantage of Object Oriented Programming (OOP). But in this particular context where a DU is not expected to ever change, the OCP is irrelevant and it is error-prone. - In the C#
switch
pattern matching scope, if we forget one of theVehicle
derived type there is no warning. However, if we forget the discard pattern_ =>
(which is useless here) we get a compiler warning.
While it’s possible to simulate Discriminated Unions in C# this way, we lack the elegant and bullet proof language support that F# provides.
Harnessing the OneOf<> Library
The OneOf<> library, found on GitHub proposes a more satisfactory approach to implementing Discriminated Unions in C#. Let’s import this library in our .csproj project file:
1 2 3 4 5 |
<ItemGroup> <PackageReference Include="OneOf"> <Version>3.0.263</Version> </PackageReference> </ItemGroup> |
Here is our program rewritten:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
using Vehicle = OneOf.OneOf<Car, Truck, Bicycle>; var v1 = new Car("Ford Mustang", 1965); var v2 = new Truck("Nissan Titan", 2018, 1930.0f); var v3 = new Bicycle("Rock Rider"); Console.WriteLine(GetVehicleDesc(v1)); Console.WriteLine(GetVehicleDesc(v2)); Console.WriteLine(GetVehicleDesc(v3)); static string GetVehicleDesc(Vehicle v) => v.Match( car => $"Car: {car.Model}, Year: {car.Year}", truck => $"Truck: {truck.Model}, Year: {truck.Year}, Payload: {truck.Payload:F2}", bicycle => $"Bicycle: {bicycle.Model}"); record Car(string Model, int Year); record Truck(string Model, int Year, float Payload); record Bicycle(string Model); |
Now we avoid the issues highlighted in the previous C# example.
Vehicle
is a type alias. We cannot add or remove an option type without modifying its definition.- Thanks to the method
OneOf<Car, Truck, Bicycle>.Match(Func<Car, string>, Func<Truck, string>, Func<Bicycle, string>)
, we have to handle all cases. - If the option type set is changed, all usages of the method
Vehicle.Match()
will break at compile time. As a consequence, we cannot avoid refactoring any call which ultimately leads to a less error-prone approach. - The
OneOf.Match()
method call is as concise as the C# switch pattern matching syntax. However, arguably, it is slightly less elegant.
Notice that OneOf<T0, T1, ..., TN>
is a structure with N+1 fields. On one hand, this design choice alleviates pressure on the garbage collector since there is no object created on the heap. On the other hand, it also means that if there are numerous option types, the structure’s footprint can grow considerably, potentially leading to performance degradation, as structure values are passed and returned by copy.
The library also proposes OneOfBase<...>
classes. This makes it possible to derive from such a class like: class StringOrNumber : OneOfBase<string, int>
Discriminated Unions, Method Result and Value Type
Arguably, Discriminated Unions find their primary usage in succinctly representing method results. Frequently, method calls yield either a value or an error.
Typically, an error is modelized using a value type such as enumerations or structures. Thus, the naive C# implementation faces limitations as inheritance is incompatible with value types. Hopefully, C# generics work perfectly with value type. Thus, here again, the OneOf library shines at handling DU method result value:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using Vehicle = OneOf.OneOf<Car, Truck, Bicycle, ErrorCode>; foreach(var v in GetVehicle()) { Console.WriteLine(GetVehicleDesc(v)); } static IEnumerable<Vehicle> GetVehicle() { yield return new Car("Ford Mustang", 1965); yield return new Truck("Nissan Titan", 2018, 1930.0f); yield return new Bicycle("Rock Rider"); yield return ErrorCode.CodeA; } static string GetVehicleDesc(Vehicle v) => v.Match( car => $"Car: {car.Model}, Year: {car.Year}", truck => $"Truck: {truck.Model}, Year: {truck.Year}, Payload: {truck.Payload:F2}", bicycle => $"Bicycle: {bicycle.Model}", err => $"Error Code: {err}" ); public record Car(string Model, int Year); public record Truck(string Model, int Year, float Payload); public record Bicycle(string Model); enum ErrorCode { CodeA, CodeB } |
Most of the time you will need a simple struct Result<TResult, TError>
to handle method result and might not need the entire OneOf library. You can develop yourself this type as suggested in this Nick Chapsas’ video. For that, you can get inspiration from the struct OneOf<T0,T1>
implementation.
Also you might think that the example of Vehicle
used so far implies that adding more kinds of vehicles could make sense someday. On the other hand, you will never need to change struct Result<TResult, TError>
one day.
What About F# And Value Type
Here is our F# initial program with a value option type Int
in the DU Vehicle
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
type Vehicle = | Car of model : string * year : int | Truck of model : string * year : int * payload : float | Bicycle of model : string | Int of value : int let v1 = Car(model = "Ford Mustang", year = 1965) let v2 = Truck(model = "Nissan Titan", year = 2018, payload = 1930.0) let v3 = Bicycle(model = "Rock Rider") let err = Int(-1) let getVehicleDesc (v: Vehicle) = match v with | Car(model, year) -> sprintf "Car: %s, Year: %d" model year | Truck(model, year, payload) -> sprintf "Truck: %s, Year: %d, Payload: %.2f" model year payload | Bicycle(model) -> sprintf "Bicycle: %s" model | Int(value) -> sprintf "Integer: %d" value printfn "%s" (getVehicleDesc v1) printfn "%s" (getVehicleDesc v2) printfn "%s" (getVehicleDesc v3) printfn "%s" (getVehicleDesc err) |
Upon decompiling the DLL produced by the F# compiler, we can see that it generates a class Int
to enable inheritance despite the value type. This resembles boxing a value type, wherein an object is instantiated on the heap solely to host a value. This issue is circumvented in the implementation of generics with struct OneOf<...,Int>
since .NET generics don’t lead to boxing value.
C# Team Discussion about C# Discriminated Unions
In September 2023, Mads Torgersen began a discussion to explore the potential development of this feature. In this proposition, Mads is more interested by how the .NET runtime should handle DU types than defining the C# syntax. Unlike the OneOf library that uses generic type with N fields…
1 2 3 4 5 6 |
public readonly struct OneOf<T0, T1, T2> { readonly T0 _value0; readonly T1 _value1; readonly T2 _value2; ... } |
…Mads suggests a DU type StringOrInt
should be represented this way:
1 2 3 4 5 6 |
[Union(typeof(string), typeof(int))] public struct Result { // Members generated by the compiler public object __value; ... |
On one hand, we avoid the expense of copying N fields every time the Discriminated Union is passed or returned. But on the other hand, value types are boxed at runtime to fit with the object __value
.
Mads concludes that a middle-ground implementation could be achieved. A specific number of bytes (like 8 bytes) alongside the object __value
reference would be reserved, allowing for the storage of small values and primitive types without requiring boxing. This is what the ValuePrototype project on github attempts to achieve.
Conclusion
As of today, C# 12 lacks native support for Discriminated Union types. This article showed that using the library OneOf offers advantages over naive attempts to implement Discriminated Union yourself. Let’s emphasize the millions of downloads of the OneOf library, indicating the community’s need for C#’s discriminated union feature.
Hopefully, the C# team is mulling over the optimal approach to implementing Discriminated Unions in C#, suggesting it could eventually become a reality.