C# Union Types: Discriminated Unions in C# 15!
As I have previously blogged about in Records as distinct types, C# has been evolving rapidly in terms of type safety and expressiveness. C# 15 and dotnet 11 finally introduces real Union Types, which allow developers to define a type that can represent multiple different types!
Not sure why this is important? Keep on reading!
What are Union Types?
Union Types, also known as Discriminated Unions or Sum Types, are a way to define a type that can hold one of several different types. This is particularly useful when you want to represent a value that can be one of several different forms. For example, you might have a Result type that can either be a Success with a value or a Error with an error message.
This allows you to write code that is more expressive and easier to understand, as you can clearly indicate the different possible states of a value.
public record class Success(string Result);
public record class Error(string Message);
public union Result(Success, Error);
We can then use pattern matching on the various possible instance of the “RequestState” (and we HAVE to do this!).
Result result = await GetDataFromApi();
var textToConsole = result switch
{
Success success => $"Success: {success.Result}",
Error error => $"Error: {error.Message}",
};
Console.WriteLine(textToConsole);
This allows the API design to surface the valid error states for example:
We could further specify the expected error states with a nested union type:
public record class Success<T>(T Value);
public union Error(FileNotFoundException, OutOfMemoryException);
public union Result<T>(Success<T>, Error);
This way we can be even more specific about the possible error states and handle them accordingly.
We can then surface that a method may throw an exception we should handle:
public record class Fizz;
public record class Buzz;
public record class FizzBuzz;
public record class None;
public union FizzBuzzResult(Fizz, Buzz, FizzBuzz, None, ArgumentOutOfRangeException);
public static FizzBuzzResult FizzBuzzForNumber(int number)
{
if (number < 0 || number > 100)
{
return new ArgumentOutOfRangeException(nameof(number), "number must be between 0 and 100");
}
if (number % 15 == 0) return new FizzBuzz();
if (number % 3 == 0) return new Fizz();
if (number % 5 == 0) return new Buzz();
return new None();
}
FizzBuzzResult result = FizzBuzzForNumber(15);
var textToConsole = result switch
{
Fizz => "Fizz",
Buzz => "Buzz",
FizzBuzz => "FizzBuzz",
None => "Snooze",
ArgumentOutOfRangeException ex => $"Error: {ex.Message}",
};
Console.WriteLine(textToConsole);
Neat! We can now represent the possible states of our data and also the possible exceptions in a clear and concise way, and we are forced to handle all cases when we use pattern matching. This is a huge win for type safety and code clarity!
Could I not just use Inheritance and Polymorphism?
Before Union Types, we often had to rely on inheritance and polymorphism to achieve similar functionality. However, this approach can lead to complex class hierarchies and can make it difficult to understand the different states of a value. With Union Types, we can represent these states more clearly and concisely without the need for complex class structures.
Example:
public abstract class Result<T> { }
public class Success<T> : Result<T>
{
public T Value { get; }
public Success(T value) => Value = value;
}
public class Error<T> : Result<T>
{
public string Message { get; }
public Error(string message) => Message = message;
}
This approach can lead to a more complex class hierarchy and can make it less clear what the different states of a value are. With Union Types, we can represent these states more clearly and concisely without the need for complex class structures.
In the code we would handle this with pattern matching as well, but it is more verbose and less clear:
Result<int> result = await GetDataFromApi();
var textToConsole = result switch
{
Success<int> success => $"Success: {success.Value}",
Error<int> error => $"Error: {error.Message}",
// The line below is a runtime error instead of a compile time error if we forget to handle a new case... Whoops my app crashed...
_ => throw new InvalidOperationException("Unknown result type"),
};
Console.WriteLine(textToConsole);
Overall, Union Types provide a powerful way to represent values that can be one of several different types, and they bring a new level of flexibility and safety to C# programming. They allow us to write code that is more expressive and easier to understand, and they can help us avoid complex class hierarchies and improve the clarity of our code.
One more thing!
Closed classes/records in C#. This is a new type similar to the union type. This lets us use the pattern from above, while keeping a lot of the benefits of the union type.
public closed record class Result<T>;
public record class Success<T>(T Value) : Result<T>;
public record class Error<T>(string Message) : Result<T>;
This allows us to have a base type that can only be inherited by a specific set of types, which can be useful for representing a closed set of possible values. It is NOT the same as sealed. The closed record is always abstract, and the base class can not be inherited in other assemblies.
This allows exhaustive switch statements and typeguards on normal classes.
Final thoughts
I’ve tested this in dotnet 11.0.0.preview-5 and I think this is a promising start of Union Types in C#.
I assume and hope that there will be shared libraries for common union types such as the Result and possibly more general union tuples.
I’d also like more “duck typing” support, where shared fields between the different union types could be used without deconstructing. union Shape(Square, Circle) -> shape.Name. It should also be possible to have function parameters in a similar style to TypeScript (for example function name(animal: Dog | Cat) { return animal.name; }). I’d expect more syntax sugar to come in the future to make this even more ergonomic.
Overall, I think this is a great addition to C# and I’m excited to see how it will be used in the future! Happy coding! 🚀
Further reading
If you want to learn more about Union Types, here are some resources I recommend. I started learning about Union Types from Elm and TypeScript, so I suggest checking out their documentation as well!
// Nils Henrik