Union types in C# 15

August 21st 2026 C# 15 C# .NET

Continuing with the topic of switch expression exhaustiveness in C# 15, I chose union types as the next new language feature to explore.

Before C# 15, there were several ways to return multiple different types from a method or store them in a single variable. You can use the type of the common ancestor of your types. This gives you two options for your types

  1. They have to implement the same interface. If the only purpose of the interface is to provide a common ancestry (i.e., it doesn't define any members to implement), such an interface is called a marker interface:

    public interface IShape { }
    
    public record Square(double Length) : IShape;
    
    public record Circle(double Radius) : IShape;
    
  2. They have to derive from the same base class (usually abstract if it's only used to provide common ancestry):

    public abstract record Shape { }
    
    public record Square(double Length) : Shape;
    
    public record Circle(double Radius) : Shape;
    

In both cases, a catch-all discard pattern has to be added to a type-based switch expression:

return shape switch
{
    Square square => Math.Pow(square.Length, 2),
    Circle circle => Math.PI * Math.Pow(circle.Radius, 2),
    _ => throw new NotSupportedException($"{shape.GetType()} is not supported."),
};

Otherwise, the following warning is generated by the compiler:

Warning CS8509 : The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern _ is not covered.

Unfortunately that same discard pattern which resolves the warning also means that the compiler can't generate a warning if you add another sibling type at a later time. That type will already be covered by the discard pattern as far as the compiler is concerned.

For the abstract base class approach, C# 15 changes this with the introduction of closed class hierarchies which I explored in my previous blog post.

But what if you can't derive your types from a common ancestor (e.g., because you are using types which you haven't defined yourself)?

public record Square(double Length);

public record Circle(double Radius);

The only obvious choice before C# 15 was to use object as the return type or variable type. Since the object type doesn't constraint the type of the assigned value in any way, the compiler of course can't reason about the exhaustiveness of a switch expression and always requires a discard pattern to be added.

The OneOf library provides one possible solution for this challenge. It introduces a generic OneOf type which you can use to combine multiple unrelated types into one. You can even give a custom name to that type by taking advantage of the using alias directives:

using Shape = OneOf<Square, Circle>;

This doesn't change how switch expressions treat these type in any way. However, the OneOf type comes with a Match method which you can use instead:

return shape.Match(
    square => Math.Pow(square.Length, 2),
    circle => Math.PI * Math.Pow(circle.Radius, 2)
);

Although the syntax is different, the functionality is equivalent. But if you add another type to your OneOf type alias:

using Shape = OneOf<Square, Circle, Rectangle>;

The code won't build anymore:

Error CS7036 : There is no argument given that corresponds to the required parameter f2 of OneOf<Square, Circle, Rectangle>.Match<TResult>(Func<Square, TResult>, Func<Circle, TResult>, Func<Rectangle, TResult>)

The number of parameters of the Match method matches the number of generic type arguments in your OneOf type declaration. Therefore, when you add another type argument, you must also add another method parameter or the code won't compile. Your code won't behave incorrectly because you forgot to do that.

With C# 15 you have an alternative to the OneOf type which is built into the language: union types. Just like the OneOf type, it allows you to specify which (unrelated) types you want to work with:

public union Shape(Square, Circle);

Unlike the OneOf type, you can now use these types in a switch expression without any warnings even if you don't include a discard pattern:

return shape switch
{
    Square square => Math.Pow(square.Length, 2),
    Circle circle => Math.PI * Math.Pow(circle.Radius, 2)
};

And when you add another type to your union:

public union Shape(Square, Circle, Rectangle/);

The compiler will generate a warning if you don't handle that new type in the switch expression:

Warning CS8509 : The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern Cs15UnionTypes.Union.Rectangle is not covered.

It's the same behavior as when using closed class hierarchies.

Of course, the feature is still in preview and it can change before the final release. You can already try it out in its current state with .NET 11 preview 7. To enable it, you have to set the language version to preview in your project:

<PropertyGroup>
  <LangVersion>preview</LangVersion>
</PropertyGroup>

I created a sample project with all five approaches from this post and put it in my GitHub repository. You can use it as a starting point to explore this feature on your own.

Union types are a complement to closed class hierarchies in C# 15. They both improve the experience when using type-based switch expressions, each for its own use case. Where closed class hierarchies address exhaustiveness for types with a common base type, unions do it for unrelated types. And give you a way to declare a predefined set of unrelated types to return from a method or assign to a variable or parameter.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License