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 behaviour is scattered. To understand what "processing a message" means, you open three files. There is no single place to read.
- You must own the types. If
TextMessagecame from a NuGet package, you cannot make it implement your interface. - Adding a new operation touches every class. Want a
Validate()alongsideProcess()? That's an interface change and three class changes.
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:
- The parameter is
object, so a caller can pass aDateTimeand the compiler accespt it. - And that
defaultat the bottom? You wrote it because the compiler made you, it cannot prove you covered everything, so it assumes you didn't.
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.
- The three types went back to being data. They are records holding values. No interface, no
Process(), no inheritance. - They need nothing in common. No shared base class, no shared interface, not even a shared member.
- The set is closed. The compiler now knows there are exactly three possibilities, because you told it.
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.
- Use an interface when the types genuinely share behaviour and the list is open.
- Use a union when the list is closed and the behaviour belongs to the caller, not to the data, message kinds, command types, result or error, etc.
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.

Join the conversation! Your thoughts help the community grow.