If you’re coming to C# from a language like Typescript, you might be wondering, “how do I write a discriminated union?” Unfortunately, C# does not currently support unions as such, but other language features can get you pretty close. Plus, native support might be just over the horizon.
The State of Union Types in C
A formal proposal to add unions to C# was added last year to the C# language repository. The proposal explores adding a concise syntax for declaring a type that can be one of several named alternatives, each potentially containing its own data payload. A first-class union type would bring C# closer to features that have been widely adopted in functional …
If you’re coming to C# from a language like Typescript, you might be wondering, “how do I write a discriminated union?” Unfortunately, C# does not currently support unions as such, but other language features can get you pretty close. Plus, native support might be just over the horizon.
The State of Union Types in C
A formal proposal to add unions to C# was added last year to the C# language repository. The proposal explores adding a concise syntax for declaring a type that can be one of several named alternatives, each potentially containing its own data payload. A first-class union type would bring C# closer to features that have been widely adopted in functional languages and increasingly in mainstream languages.
Rumor has it that C# 15, expected in late 2026, may finally include union types. Of course, if unions really do land within the next year, you likely won’t be able to take advantage of them right away. Even now you’re probably already held back from using the latest C# version by various tooling, third-party dependencies, and environmental constraints. So what can you do until then?
Approximating Unions
The first thing to realize is that although a language like Typescript uses a structural type system, C# has a nominal type system. Typescript creates unions by tagging each case with a unique value. Very often this is a field named type with a string value. In C#, the name of the type is its “tag”.
Option 1. Use a Record Hierarchy With Pattern Matching
This leads to the most straightforward solution, which is to define a hierarchy of record types:
- An abstract base record, and
- A set of sealed nested record types, one for each union case
Record types are great for this use case, since they come with value-equality semantics. This means that two instances of the type can be compared using == without any additional work. Historically you’d have to override a bunch of things like Equals, HashCode, operator== , etc – and update it every time you added new properties.
Nesting the types also gives you a convenient namespacing effect. With all that in place, you can use switch expressions (or switch statements) to handle the individual cases.
Example
public abstract record Shape
{
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Triangle(double A, double B, double C) : Shape;
}
// Usage
double Area(Shape shape) => shape switch
{
Shape.Circle c => Math.PI * c.Radius * c.Radius,
Shape.Rectangle r => r.Width * r.Height,
Shape.Triangle t => HeronsFormula(t),
_ => throw new ArgumentException("Unknown shape case")
};
This pattern works well and feels natural in modern C#. But it comes with one major drawback: switching on a type cannot be made exhaustive, so you need a default case (typically just to throw an exception). Exhaustiveness checking is handy because it lets us know that all possible cases have been handled. So if a new case is added (e.g. a Hexagon in the example above), the compiler would flag all the places that haven’t handled it.
If we were switching over an enum, we could write one branch for each case of the enum without needing a default case. Enums cannot be extended, so the compiler can verify that every possible branch has been covered. But since classes/records can be extended by loading assemblies at runtime, the compiler cannot guarantee that no other Shape types will show up. Sealing the nested types is a feeble attempt at preventing rogue subclasses, but there’s no way to mark the base class as “sealed, except for these”.
Option 2. An Enum-Driven Union
One of the defining strengths of union types is their ability to store different data on each case. But if you don’t need that, or if you’re ok with having a bunch of nullable properties that aren’t used in all cases, you can trade the record hierarchy for an enum tag (aka the typescript style).
Example
public enum OperationState
{
Pending,
Running,
Completed,
Failed
}
public record OperationStatus(
OperationState State,
TimeSpan? Runtime = null,
string? ErrorMessage = null
);
// Usage
string Display(OperationStatus status) => status.State switch
{
OperationState.Pending => "Waiting…",
OperationState.Running => $"In progress for {status.Runtime}…",
OperationState.Completed => "Done!",
OperationState.Failed => $"Error: {status.ErrorMessage}",
};
Hooray, no pointless default case! This approach isn’t as expressive as a full discriminated union, since you can’t enforce that certain fields only exist on certain cases. But it may be worth the trade-off in some scenarios.