In this post, let's have a look at Union types in C# and a subtle
difference you will notice when you migrate from a closed class hierarchy
to a union.
Some time ago I blogged about
C# 15.0: Closed Class Hierarchies. There we used the closed keyword to model a fixed set of
Shape types and let the compiler enforce exhaustive pattern
matching.
C# 15.0 also introduces union types, which solve a very similar
problem: a value that must be exactly one of a fixed set of types, with
exhaustiveness checked by the compiler.
I am using .NET 11 Preview 6 (latest as of today, it will change) for
this post.
You will also want to set the following in your project file to opt
into the latest language features.
<LangVersion>preview</LangVersion>
Let's start with where we left off, the closed hierarchy version.
Shape[] shapes = [ new Circle(2), new Rectangle(3, 4), new Triangle(4, 5), ]; foreach (Shape shape in shapes) { Console.WriteLine($"{shape.GetType().Name}: {Area(shape):0.00}"); } static double Area(Shape shape) => shape switch { Circle(var r) => Math.PI * r * r, Rectangle(var w, var h) => w * h, Triangle(var b, var h) => 0.5 * b * h, }; public closed record class Shape; public record class Circle(double Radius) : Shape; public record class Rectangle(double Width, double Height) : Shape; public record class Triangle(double Base, double Height) : Shape;
And the output.
Circle: 12.57 Rectangle: 12.00 Triangle: 10.00
Nothing surprising here. Each element in the array is really a
Circle, a Rectangle or a Triangle that derives from the
Shape base type, so shape.GetType().Name reports the concrete
type.
Now let's rewrite this using a union. Notice that Circle,
Rectangle and Triangle no longer inherit from anything, they
are just plain records. The union declaration composes them into a
closed set.
public record class Circle(double Radius); public record class Rectangle(double Width, double Height); public record class Triangle(double Base, double Height); public union Shape(Circle, Rectangle, Triangle);
The array initializer, the foreach and the Area switch expression are all untouched, and everything still compiles. That works because an implicit union conversion exists from each case type to the union, so a Circle, Rectangle or Triangle is silently converted to a Shape. But have a look at the output.
Shape: 12.57 Shape: 12.00 Shape: 10.00
The areas are still correct, but GetType().Name now returns
Shape for every element. Why?
When you declare a union, the compiler generates a struct that
implements IUnion and stores the actual case value in an
object? property named Value. So
public union Shape(Circle, Rectangle, Triangle); becomes roughly
equivalent to this.
[Union] public struct Shape : IUnion { public Shape(Circle value) => Value = value;
public Shape(Rectangle value) => Value = value;
public Shape(Triangle value) => Value = value; public object? Value { get; } }
So inside the foreach, shape is the generated
Shape struct wrapping a case value, not the case value itself.
Calling GetType() on it boxes the struct and reports Shape.
The Circle, Rectangle or Triangle instance lives inside
the Value property.
This is also why the Area switch expression keeps working without any
changes. Pattern matching on a union is applied to Value, so the
union is transparent to the patterns and Circle(var r),
Rectangle(var w, var h) and Triangle(var b, var h) still
match.
If you actually want the concrete case type name, reach through
Value.
foreach (Shape shape in shapes) { Console.WriteLine($"{shape.Value?.GetType().Name}: {Area(shape):0.00}"); }
And now we are back to the concrete names.
Circle: 12.57 Rectangle: 12.00 Triangle: 10.00
That is the key mental model difference between the two features. A
closed class hierarchy is real inheritance, so the runtime identity
is the derived type and GetType() sees it. A union is a
generated struct over a closed set of case types, so the runtime
identity is the union, and you get to the case value through
Value (or through pattern matching, which does the unwrapping for
you).
More read:
Explore union types in C# 15
Explore union types in C# 15
Hope this helps.
Happy Coding.
Regards,
Jaliya
