Custom union types in C# 15
In the previous blog post I explored what union types bring to C#. But what does the compiler do under the hood? And do I need to know about that to fully take advantage of union types?
Each union type implements the IUnion interface with a single Value property:
public interface IUnion
{
object? Value { get; }
}
To initialize the Value property, a constructor is generated for each case type, e.g.:
public Shape(Square square)
{
Value = square;
}
This constructor is used by the implicit conversion operator which allows us to directly assign an instance of any case type to the union type:
Shape shape = new Square(2);
Since the union type is just a regular type, we can add custom members to it:
public union Shape(Square, Circle)
{
public double Area =>
this switch
{
Square square => Math.Pow(square.Length, 2),
Circle circle => Math.PI * Math.Pow(circle.Radius, 2)
};
}
By following the requirements for a union type we can even implement our own custom union type which supports exhaustive switch expressions just like the autogenerated one. It has to:
- implement the
IUnioninterface - have a constructor for each case type with its instance as the only parameter
- return that case type instance via the
Valueproperty - be annotated with the
[Union]attribute
[Union]
public struct CustomShape : IUnion
{
public object? Value { get; }
public CustomShape(Square square)
{
Value = square;
}
public CustomShape(Circle circle)
{
Value = circle;
}
public static implicit operator CustomShape(Square square) => new(square);
public static implicit operator CustomShape(Circle circle) => new(circle);
}
The implicit conversion operators are optional. If we don't implement them, the compiler will generate them for us.
But why would we even want to implement our own custom union type when the compiler can do it for us? It could be for performance reasons. The compiler-generated union type always stores the case type instance as an object just like the struct above. If our case types are value types, this means that they are boxed every time they are stored in the union type and unboxed every time they are read from it. We can avoid this with a custom union type:
[Union]
public readonly struct IntOrDouble : IUnion
{
private enum ValueType : byte
{
None,
Int,
Double,
}
private readonly int _intValue;
private readonly double _doubleValue;
private readonly ValueType _type = ValueType.None;
public object? Value =>
_type switch
{
ValueType.Int => _intValue,
ValueType.Double => _doubleValue,
_ => null,
};
public IntOrDouble(int value)
{
_type = ValueType.Int;
_intValue = value;
}
public IntOrDouble(double value)
{
_type = ValueType.Double;
_doubleValue = value;
}
public bool TryGetValue(out int value)
{
value = _intValue;
return _type == ValueType.Int;
}
public bool TryGetValue(out double value)
{
value = _doubleValue;
return _type == ValueType.Double;
}
}
Notice the two TryGetValue methods in this custom union type. When they are present, the compiler uses them in favor of the Value property when the union type is used with type matching, e.g.,
if (union is int value)
{
// ...
}
These methods make sure that boxing and unboxing can be completely avoided. Of course, if the Value property is accessed directly, the value will still be boxed, as there is no other way to return a value type as an object.
As already mentioned in the previous blog post, union types are still in preview and can change before the final release, but they are already available to try out in .NET 11 preview 7. You have to set the language version to preview in your project for them to work:
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
You can find a sample project with all the code from this blog post and more in my GitHub repository. Feel free to clone it and play around with it.
Although you can use union types as soon as you understand their basic syntax, it's interesting to learn what the compiler does for you. And while it's unlikely that you'll need to implement your own custom union type, it's good to know that it's possible.
