C# 15 gets union types
Contents
Union types have been one of the longest-standing feature requests for C#, and they finally landed in C# 15 / .NET 11 Preview 2 as a preview feature.
The new union keyword lets you declare “a value is exactly one of a fixed set of types,” and the compiler verifies exhaustiveness of switch expressions.
This article covers the design and implementation based on the official blog post.
Basic syntax
The simplest declaration looks like this:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
Just list the case types after the union keyword.
Case types don’t need to share an inheritance hierarchy — completely unrelated types are fine.
Assignment uses implicit conversion:
Pet pet = new Dog("Rex");
Console.WriteLine(pet.Value); // Dog { Name = Rex }
Pattern matching doesn’t require _ or default when all cases are covered:
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
Forget one case and the compiler emits an error.
What used to require object or an abstract base class to express “this variable is one of three types” can now be declared in a type-safe way.
The many meanings of “union”
“Union” carries multiple different meanings in programming, so it’s worth clarifying what C# 15’s union is and isn’t.
C’s union is a memory-layout sharing mechanism.
All members occupy the same memory region, and the programmer must manually track which member is active.
There’s no type safety — in C++, accessing the wrong member is undefined behavior.
union Value {
int i;
float f;
};
Value v;
v.i = 42;
printf("%f\n", v.f); // undefined behavior
SQL’s UNION is an operator that combines result sets of queries — an entirely different concept from type systems.
You use SELECT ... UNION SELECT ... routinely in MySQL and PostgreSQL, but it has nothing to do with union types.
Set-theoretic union refers to the combination of two sets.
TypeScript’s A | B and Python’s Union[A, B] take their names from this mathematical concept.
C# 15’s union is none of the above.
It defines a closed set of types and provides compiler-guaranteed exhaustive pattern matching.
Despite sharing a name with C’s memory-sharing union, the design philosophy is fundamentally different.
Union-like features across languages
The ability to express “a value is exactly one of a finite set of types” exists in many languages.
Union types, discriminated unions, algebraic data types (ADTs), sealed hierarchies — the names vary, but the problem being solved is the same.
Below is a comparison organized by approach.
Memory-sharing: C and C++
C’s union shares memory as described above.
With no type safety, you end up building manual tagged unions with struct + enum if you want tags.
typedef enum { INT_VAL, FLOAT_VAL, STR_VAL } ValueTag;
typedef struct {
ValueTag tag;
union { int i; float f; const char* s; };
} TaggedValue;
The moment you forget to update the tag, you have a bug.
After decades of exactly this kind of accident, C++17 added std::variant to the standard library.
std::variant<int, float, std::string> v = 42;
std::visit(overloaded {
[](int i) { std::cout << "int: " << i; },
[](float f) { std::cout << "float: " << f; },
[](const std::string& s) { std::cout << "str: " << s; }
}, v);
std::visit gets you close to pattern matching, but the compiler does not guarantee exhaustiveness.
Missing a case still compiles.
Algebraic data types: the discriminated union lineage
The approach pioneered by Haskell and ML-family languages.
Each case gets a name (constructor or tag) and you branch with pattern matching.
Compiler-checked exhaustiveness is the shared trait.
Haskell is the canonical example.
You declare constructors with data and get a compile-time warning if the match is incomplete.
data Shape = Circle Double
| Rectangle Double Double
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
F# is the functional language on .NET, and discriminated unions are a core feature.
The compiler verifies match exhaustiveness.
Living on the same .NET runtime yet lacking this feature, C# has long drawn the question “why doesn’t C# have this?” from F# users.
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
let area shape =
match shape with
| Circle r -> System.Math.PI * r * r
| Rectangle(w, h) -> w * h
Rust’s enum is composed of variants that can carry data.
Combined with the ownership system, it’s memory-safe and zero-cost.
match requires exhaustiveness — missing a case is a compile error.
What makes Rust particularly impressive is niche optimization.
For Option<&T>, the compiler knows that a reference can never be 0 (null), so it reuses 0 as the discriminant for None.
As a result, Option<&T> is the same size as a raw reference — the tag costs zero memory.
enum Shape {
Circle(f64),
Rectangle(f64, f64),
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
}
}
Swift’s enum can also carry associated values.
switch requires exhaustiveness.
enum Shape {
case circle(radius: Double)
case rectangle(width: Double, height: Double)
}
func area(_ s: Shape) -> Double {
switch s {
case .circle(let r): return .pi * r * r
case .rectangle(let w, let h): return w * h
}
}
OCaml is a direct descendant of the ML family, where variant types and pattern matching are at the core of the language.
It has roughly the same expressive power as Haskell.
type shape =
| Circle of float
| Rectangle of float * float
let area = function
| Circle r -> Float.pi *. r *. r
| Rectangle (w, h) -> w *. h
What all these languages share is that each case defines a new constructor name.
Rather than tossing in existing types directly, you give each case a dedicated name like Circle of float.
The upside is that domain meaning is baked into the type definition; the downside is that reusing existing types requires writing wrappers.
Type unions: using existing types directly
The approach where you don’t define new constructors but instead collect existing types into a set.
C# 15 falls into this category.
TypeScript’s union type uses | to combine types — probably the most familiar form for anyone in the JavaScript/TypeScript ecosystem.
type StringOrNumber = string | number;
function format(value: StringOrNumber): string {
if (typeof value === "string") return value.toUpperCase();
return value.toFixed(2);
}
For discriminated unions, the go-to pattern is a literal-type tag field:
type Shape =
| { type: "circle"; radius: number }
| { type: "rectangle"; width: number; height: number };
function area(s: Shape): number {
switch (s.type) {
case "circle": return Math.PI * s.radius ** 2;
case "rectangle": return s.width * s.height;
}
}
TypeScript’s switch can check exhaustiveness via --strictNullChecks and the never type, but it’s not on by default and depends on project configuration.
Scala 3’s union type lets you compose types without a declaration:
def format(value: Int | String): String = value match
case i: Int => i.toString
case s: String => s.toUpperCase
The major difference from C# 15 is anonymity.
Scala 3’s Int | String is an unnamed, ad-hoc type, unlike C# where union explicitly defines a new named type.
Exhaustiveness checking in match only works when combined with sealed traits — bare union types don’t get it.
Python has supported the X | Y syntax for union types since 3.10:
def format(value: int | str) -> str:
if isinstance(value, str):
return value.upper()
return f"{value:.2f}"
Python’s type annotations aren’t enforced at runtime.
Without a static type checker like mypy or pyright, passing a float won’t produce any error.
Exhaustiveness checking has only started appearing in mypy 1.x as partial support, far from a compiler guarantee.
Sealed hierarchies: closed type sets via restricted inheritance
Languages that don’t have a union keyword but create closed type sets by restricting inheritance.
This sits somewhere between discriminated unions and type unions.
Kotlin’s sealed class restricts direct subclasses to the same package.
When a when expression (Kotlin’s switch equivalent) covers all subclasses, else becomes unnecessary.
sealed interface Shape
data class Circle(val radius: Double) : Shape
data class Rectangle(val width: Double, val height: Double) : Shape
fun area(s: Shape): Double = when (s) {
is Circle -> Math.PI * s.radius * s.radius
is Rectangle -> s.width * s.height
}
Java gained sealed classes in Java 17 and switch expression pattern matching (finalized) in Java 21.
Records combined with sealed interfaces give it the same expressive power as Kotlin.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
Java 21’s switch expression verifies exhaustiveness for sealed types.
Both Kotlin and Java produce compile errors when a case is added and the switch/when isn’t updated.
The downside of sealed hierarchies is that every new case requires defining a new class/record and implementing the interface.
Expressing a simple union like “either int or string” requires two wrapper classes, adding boilerplate.
Comparison
| Language | Approach | Exhaustiveness | Reuse existing types | Memory efficiency |
|---|---|---|---|---|
| C | untagged union | - | - | Excellent (memory sharing) |
C++17 variant | tagged union | - | Yes | Good |
| Haskell | ADT | Yes | - | Good |
| OCaml | variant type | Yes | - | Good |
| F# | discriminated union | Yes | - | Good |
Rust enum | ADT | Yes | - | Excellent (niche opt.) |
Swift enum | ADT | Yes | - | Good |
| TypeScript | type union | Partial (config) | Yes | N/A |
| Scala 3 | type union | Partial (sealed only) | Yes | JVM-dependent |
| Python | type union | Partial (tooling) | Yes | N/A |
| Kotlin | sealed hierarchy | Yes | - | JVM-dependent |
| Java 21 | sealed hierarchy | Yes | - | JVM-dependent |
| C# 15 | type union | Yes | Yes | Fair (boxing) |
What makes C# 15 unusual is the combination of “type union + compiler-guaranteed exhaustiveness.”
TypeScript and Scala 3 type unions have weak exhaustiveness that depends on configuration or sealed traits.
Kotlin/Java sealed hierarchies have strong exhaustiveness but require dedicated type definitions for each case.
C# 15 lets you list existing record classes and structs directly while the compiler guarantees full-case coverage.
No other major language occupies this position.
There are trade-offs, of course.
Internally, values are stored in an object? field, so value types get boxed, and there’s no Rust-style niche optimization.
For performance-critical scenarios, you’ll need the non-boxing pattern described later.
If you want discriminated unions, a separately proposed “closed hierarchy” feature (discussed later) should make F#-style patterns expressible in C# as well.
Generics and method definitions
Union declarations support generic type parameters.
You can also give them a body with methods.
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
T single => [single],
IEnumerable<T> multiple => multiple,
null => []
};
}
Usage is straightforward:
OneOrMore<string> tags = "dotnet";
OneOrMore<string> moreTags = new[] { "csharp", "unions", "preview" };
foreach (var tag in tags.AsEnumerable())
Console.WriteLine(tag);
Note the null arm in the switch over Value.
The default value of a union type has Value set to null, so the compiler includes the null case in exhaustiveness checking.
Internal implementation and UnionAttribute
A compiler-generated union declaration is internally implemented as a struct (record struct) with a single object? field.
Value-type cases are boxed.
To give union behavior to your own types, apply [System.Runtime.CompilerServices.Union].
As of Preview 2, this attribute isn’t included in the runtime, so you have to declare it yourself:
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class UnionAttribute : Attribute;
public interface IUnion
{
object? Value { get; }
}
}
Library authors can retrofit union behavior onto existing types.
Implement “union creation members” (constructors) and the Value property, and the compiler provides implicit conversions and exhaustive pattern matching.
Non-boxing access pattern
The Value property is object?, so value-type cases like int or bool incur boxing.
This is the “Fair (boxing)” entry from the comparison table.
For performance-sensitive scenarios, you can implement a “non-boxing union access pattern”:
[Union]
public record struct IntOrBool
{
private bool _isBool;
private int _value;
public IntOrBool(int value) => (_isBool, _value) = (false, value);
public IntOrBool(bool value) => (_isBool, _value) = (true, value ? 1 : 0);
public object Value => _isBool ? _value is 1 : _value;
public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool;
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value is 1;
return _isBool;
}
}
When you implement a HasValue property and per-case-type TryGetValue methods, the compiler generates pattern-matching code that extracts values without boxing.
This doesn’t work with the union declaration sugar, but it matters when a library provides a custom implementation via [Union].
It doesn’t match Rust enum’s niche optimization, but it’s a reasonable approach for avoiding boxing in .NET’s GC environment.
How exhaustiveness checking works
C# 15’s union matching behaves differently depending on the pattern kind:
flowchart TD
A["switch expression<br/>receives union value"] --> B{"Pattern kind?"}
B -->|"var / _"| C["Applied to the<br/>union value itself"]
B -->|"Type pattern<br/>null pattern<br/>property pattern"| D["Auto-unwraps<br/>.Value property"]
D --> E{"All case types<br/>covered?"}
E -->|Yes| F["Compiles<br/>no default needed"]
E -->|No| G["Compile error<br/>CS8509"]
Writing var pet binds the union value itself.
A type pattern like Dog d automatically unwraps .Value and matches against its contents.
Thanks to this auto-unwrapping, callers don’t need to write pet.Value switch { ... }.
When the union value is a class type, the null pattern matches both when the union value itself is null and when Value is null.
For struct unions, it only matches the case where Value is null.
While Haskell/Rust/Swift check exhaustiveness by asking “are all constructors listed?”, C# 15 asks “are all declared case types covered?”
The result is the same, but the unit of checking is “constructor name” vs. “type.”
Applying union types to Result
Using the union member provider pattern, you can naturally implement a Result type familiar from functional languages:
[Union]
public record class Result<T> : Result<T>.IUnionMembers
{
object? _value;
public interface IUnionMembers
{
public static Result<T> Create(T value) => new() { _value = value };
public static Result<T> Create(Exception value) => new() { _value = value };
public object? Value { get; }
}
object? IUnionMembers.Value => _value;
}
This pattern uses the IUnionMembers interface as a union-defining type, defining case types through Create factory methods rather than constructors.
You get full control over internal storage strategy while retaining the compiler’s exhaustiveness guarantee.
The equivalent of F#‘s Result<'T, 'TError> or Rust’s Result<T, E> can now be written type-safely in C#.
Compared to the old options — relying on exceptions or faking it with (bool success, T? value, Exception? error) tuples — this is a significant step forward.
Roadmap
Several proposals are in progress alongside union types.
closed hierarchy adds a closed modifier to classes, restricting derived class declarations to the defining assembly.
This is the C# counterpart to Kotlin/Java’s sealed classes, enabling exhaustive pattern matching on abstract-class-based discriminated unions.
The specification proposal is already public.
Combined with union types, this should make F#-style discriminated union patterns expressible in C#.
closed enum creates enums that cannot hold values outside the declared members, preventing casts like (MyEnum)999.
union member provider would enable direct access to properties shared across case types, but it’s not yet implemented as of Preview 2.
It’s slated for a future preview.
Minimal API response types
ASP.NET Core 7 introduced Results<T1, T2, ...> for endpoints that return multiple HTTP response types:
app.MapGet("/users/{id}", Results<Ok<User>, NotFound> (int id) =>
{
var user = db.Find(id);
return user is not null
? TypedResults.Ok(user)
: TypedResults.NotFound();
});
Internally, this uses generic types from Results<T1, T2> through Results<T1, ..., T6>, capping at six types.
Union types remove this limitation:
public union ApiResponse(
Ok<User>,
NotFound,
BadRequest<ValidationProblem>,
Conflict,
TooManyRequests,
ServiceUnavailable,
InternalServerError
);
app.MapGet("/users/{id}", ApiResponse (int id) =>
{
// Seven or more types — no problem
});
Integration with OpenAPI (Swagger) automatic schema generation is not yet supported as of Preview 2.
Auto-generating response schemas from a union’s case list is a natural extension, and support may land before GA.
Blazor component state
“Loading / success / error” is a pattern that comes up constantly in SPAs.
In Blazor, this typically meant separate fields — bool isLoading, T? data, string? error — with inconsistent states like “loading but data isn’t null” invisible until runtime.
public record Loading;
public record Loaded<T>(T Data);
public record Failed(string Message);
public union PageState<T>(Loading, Loaded<T>, Failed);
The component side branches with a switch:
@switch (State)
{
case Loading:
<Spinner />
break;
case Loaded<WeatherForecast[]> loaded:
<ForecastTable Data="@loaded.Data" />
break;
case Failed f:
<ErrorBanner Message="@f.Message" />
break;
}
The three states are mutually exclusive at the type level, so invalid state combinations are caught at compile time.
The concept is the same as using tagged unions for state management in React with TypeScript, but in C# you get the compiler’s exhaustiveness guarantee.
When a case is added, forgetting to update the switch becomes a compile error — which matters a lot as the number of screens in a project grows.
System.Text.Json serialization status
If you’re using union types in a web API, JSON serialization behavior is unavoidable.
Here’s how System.Text.Json handles it as of Preview 2.
A union type’s Value property is object?, so the default serializer serializes based on the runtime type:
public union ApiResult(User, ErrorInfo);
var result = (ApiResult)new User("Alice", "alice@example.com");
var json = JsonSerializer.Serialize(result);
// {"Value":{"Name":"Alice","Email":"alice@example.com"}}
Serialization works, but deserialization loses type information and can’t round-trip without help.
You need either a custom JsonConverter or .NET 7’s JsonDerivedType attribute for polymorphic serialization:
public class ApiResultConverter : JsonConverter<ApiResult>
{
public override ApiResult Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
// Example: branch on a discriminant field
if (root.TryGetProperty("Email", out _))
return JsonSerializer.Deserialize<User>(root, options)!;
return JsonSerializer.Deserialize<ErrorInfo>(root, options)!;
}
public override void Write(
Utf8JsonWriter writer,
ApiResult value,
JsonSerializerOptions options)
{
JsonSerializer.Serialize(
writer,
value.Value,
value.Value?.GetType() ?? typeof(object),
options);
}
}
This area is still rough.
It’d be nice to have the serializer automatically attach a discriminant field (like "type": "user") the way TypeScript does.
Whether System.Text.Json gets native union support before GA, or whether it’s left to third-party JsonConverter factories, should become clearer in future previews.
Trying it out
Install the .NET 11 Preview 2 SDK and set the following in your project file:
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
Since UnionAttribute and IUnion aren’t included in the runtime as of Preview 2, you’ll need to add the declarations shown above to your project.
Mads Torgersen from the C# team hasn’t committed to union types shipping in C# 15 (scheduled for November 2026).
Serious work begins after C# 14 GAs, and it ships if it’s ready in time.
Preview-stage feedback influences the design, so if you want to weigh in, the GitHub issue is the place to do it.