Tuesday, July 21, 2026

C# 15: Brand New Union Types

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).

Hope this helps.

Happy Coding.

Regards,
Jaliya

No comments:

Post a Comment