Every developer come across this problem: You have many of related things (text message, an image message, a video message) and you need one method that take any of them and do the right thing.

Until now we have two ways to do that, C# 15 adds a third. This article walks through all three, slowly, with the code in front of you.

The problem, in one sentence

You want a parameter whose type is "one of these three types", and you want the compiler to make sure you handled all three.

The way we do it today: an interface

Here's the version almost every codebase has:

public interface IMessage
{
    void Process();
}

public class TextMessage : IMessage
{
    public string Content { get; }
    public TextMessage(string content) => Content = content;

    public void Process()
    {
        Console.WriteLine("Text message: " + Content);
    }
}

public class ImageMessage : IMessage
{
    public byte[] ImageData { get; }
    public ImageMessage(byte[] imageData) => ImageData = imageData;

    public void Process()
    {
        Console.WriteLine("Received an image with size: " + ImageData.Length);
    }
}

public class VideoMessage : IMessage
{
    public byte[] VideoData { get; }
    public VideoMessage(byte[] videoData) => VideoData = videoData;

    public void Process()
    {
        Console.WriteLine("Received a video with size: " + VideoData.Length);
    }
}

void ProcessMessage(IMessage message)
{
    message.Process(); // Calls the appropriate method based on the actual message type
}

This works. It is normal, correct C#. But look closely at what it costs:

The other way: a switch

So people often skip the interface and switch on the type instead:

void ProcessMessage(object message)
{
    switch (message)
    {
        case TextMessage text:
            Console.WriteLine("Text message: " + text.Content);
            break;
        case ImageMessage image:
            Console.WriteLine("Received an image with size: " + image.ImageData.Length);
            break;
        case VideoMessage video:
            Console.WriteLine("Received a video with size: " + video.VideoData.Length);
            break;
        default:
            throw new ArgumentException("Unknown message type");
    }
}

Now the behaviour is in one readable place. But we traded one problem for another:

C# 15: Union Type

A union type lets you declare, in one line, exactly which types are allowed:

public record class TextMessage(string Content);
public record class ImageMessage(byte[] ImageData);
public record class VideoMessage(byte[] VideoData);

public union Message(TextMessage, ImageMessage, VideoMessage);

Read that last line as: a Message is a TextMessage, or an ImageMessage, or a VideoMessage. Nothing else.

Now the method:

void ProcessMessage(Message message)
{
    switch (message)
    {
        case TextMessage text:
            Console.WriteLine("Text message: " + text.Content);
            break;
        case ImageMessage image:
            Console.WriteLine("Received an image with size: " + image.ImageData.Length);
            break;
        case VideoMessage video:
            Console.WriteLine("Received a video with size: " + video.VideoData.Length);
            break;
    }
}

Same body as before, with two differences: the parameter is Message instead of object, and the default is gone.

What the compiler generated for you

Writing public union Message(...) makes the compiler produce a type with two properties.

Implicit conversions from every case type:

Message m = new TextMessage("hello");   // works
ProcessMessage(new ImageMessage(bytes)); // also works

Try to pass something that isn't one of the three and you get a compile error, not a run-time exception.

"Exhaustive": what it actually means

This is the word you'll see everywhere, so let's be precise.

A switch expression is exhaustive when it handles every case type of the union. When it is, you don't need a discard pattern (_) or a var pattern to catch "everything else".

string Describe(Message message) => message switch
{
    TextMessage text   => "Text: " + text.Content,
    ImageMessage image => $"Image, {image.ImageData.Length} bytes",
    VideoMessage video => $"Video, {video.VideoData.Length} bytes",
};

No _ => throw new .... The compiler knows the list is complete.

Now every switch expression over Message that doesn't handle AudioMessage produces a compiler warning. Not a crash six weeks later from a customer. A warning, on your machine, the moment you change the union.

When not to use a union

Unions are not a replacement for interfaces. They solve a different problem.

If adding a new type to the set should force you to revisit every place that handles it, that's a union. If it shouldn't, that's an interface.