Skip to main content

C# 15 Union Types: Examples, Exhaustive Switch and Gotchas

Last updated: October 22, 2026 · Tested with .NET 11 RC2 (SDK 11.0.100-rc.2.26504.105, C# 15) and the .NET 10.0.12 runtime

Short answer: C# 15 adds union types. You declare one with the union keyword and a list of case types. Any value of a case type converts to the union implicitly, and a switch expression over the union is checked for exhaustiveness: no _ => arm needed, and you get a warning if you forget a case.

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);
// 1. Basics: implicit conversion and an exhaustive switch
Pet pet = new Dog("Rex");
Console.WriteLine($"1. {Describe(pet)}");

static string Describe(Pet pet) => pet switch
{
    Dog d => $"dog {d.Name}",
    Cat c => $"cat {c.Name}",
    Bird b => $"bird {b.Name}",
};

That's the feature most C# developers have asked for under the name "discriminated unions". I built and ran every example below on the RC2 SDK and recorded the real compiler messages. A few things surprised me: a union is a struct that boxes, default slips straight past the "exhaustive" switch without any warning, System.Text.Json can't read most unions back without extra setup, and on .NET 10 the compiler errors don't tell you what's wrong.

Set Up: .NET 11 SDK and C# 15

.NET 11 is still a release candidate (RC2, supported for production use); the final version ships November 10, and I'll retest these examples on it.

C# 15 is the default language version for projects that target net11.0, so you don't need <LangVersion>preview</LangVersion>. Install the .NET 11 SDK (RC2 today, the final release from November 10) from dotnet.microsoft.com, or use a current Visual Studio 2026 Insiders build. The project file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

I used the official RC2 zip, checked its SHA-512 against Microsoft's release metadata, and ran it from a folder, so my installed .NET 10 SDK stayed untouched.

Declaring a Union and Matching on It

A union lists its case types in parentheses. The case types already exist; the union doesn't declare new ones. Records are a natural fit, but any type works, including int and string:

public union Pet(Cat, Dog, Bird);
public union Result(int, string);

Each case type converts implicitly, so Pet pet = new Dog("Rex"); and Result ok = 42; just work. You read the value back with type patterns in switch and is:

Result ok = 42;
Result err = "boom";
Console.WriteLine($"3. {Show(ok)} / {Show(err)}");
static string Show(Result r) => r switch
{
    int n => $"ok {n}",
    string s => $"error {s}",
};
1. dog Rex
3. ok 42 / error boom
5. pet is Dog: True, pet is Cat: False

A union can also have members. Declare it partial and add the rest in a second declaration:

public partial union Pet
{
    public string Name => this switch
    {
        Cat c => c.Name,
        Dog d => d.Name,
        Bird b => b.Name,
    };
}

With public partial union Pet(Cat, Dog, Bird); elsewhere, pet.Name printed Tom for a cat.

The Compiler Errors and Warnings You'll See

These are the exact messages from the RC2 compiler. Forget a case and you get the familiar CS8509, now naming the missing case type. It is a warning, so add <WarningsAsErrors>CS8509</WarningsAsErrors> if you want a missing case to break the build:

Pet pet = new Dog("Rex");
string name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
};
  Program.cs(2,19): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bird' is not covered.
  run: Rex

Assign a type that isn't a case, or test for one, and you get hard errors:

  Program.cs(1,11): error CS0029: Cannot implicitly convert type 'Fish' to 'Pet'
  Program.cs(2,12): error CS8121: An expression of type 'Pet' cannot be handled by a pattern of type 'Fish'.

One trap: switch on pet.Value instead of pet and you lose exhaustiveness checking. Value is typed object, so the compiler asks for a discard arm even though all three cases are covered:

  Program.cs(2,25): 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.
  run: bird

What the Compiler Generates: a Struct That Boxes

I inspected Pet with reflection. The union is a sealed struct that implements System.Runtime.CompilerServices.IUnion, has one constructor per case type, and stores the case in a single object Value property:

2. Pet is a struct, sealed=True
   interfaces: System.Runtime.CompilerServices.IUnion
   attributes: System.Runtime.CompilerServices.NullableContextAttribute, System.Runtime.CompilerServices.NullableAttribute, System.Runtime.CompilerServices.UnionAttribute
   Method       System.Object get_Value()
   Constructor  Void .ctor(Cat)
   Constructor  Void .ctor(Dog)
   Constructor  Void .ctor(Bird)
   Property     System.Object Value

Because the storage is object, a value-type case is boxed. I created 1,000 Result values from ints and measured with GC.GetAllocatedBytesForCurrentThread():

long before = GC.GetAllocatedBytesForCurrentThread();
int sum = 0;
for (int i = 0; i < 1000; i++)
{
    Result r = i;
    if (r is int n) sum += n;
}
long after = GC.GetAllocatedBytesForCurrentThread();
   1000 Result values from int: 24000 bytes allocated (sum 499500)

That's 24 bytes per value, the size of a boxed int on x64. For reference-type cases (records, classes) there's no extra allocation, since the object already lives on the heap. In a hot path with int, double or struct cases, the union allocates where a hand-written struct wouldn't. Keep using your own struct (or a generic Result<T>) there.

Trap: default and null Bypass the Exhaustive Switch

Because a union is a struct, default(Pet) is a valid value with Value == null. The switch from the top of this post compiled with zero warnings, but at run time:

4. default(Pet) -> SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Pet.

The same happens with an uninitialized field, an array of unions (new Pet[10]) or a default parameter. Converting a null reference does produce warnings, and it fails the same way:

Pet pet = (Dog?)null;
Console.WriteLine(pet switch { Dog d => d.Name, Cat c => c.Name, Bird b => b.Name });
  Program.cs(1,11): warning CS8604: Possible null reference argument for parameter 'value' in 'Pet.Pet(Dog value)'.
  Program.cs(2,23): warning CS8655: The switch expression does not handle some null inputs (it is not exhaustive). For example, the pattern 'null' is not covered.
  run: Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
  run: Unmatched value was Pet.

If a union can be default in your code, add a null arm. This compiled with no warnings and printed (no pet) and then True:

Pet pet = default;
string name = pet switch
{
    null => "(no pet)",
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};
Console.WriteLine(name);
Console.WriteLine(pet.Value is null);

Exhaustive means "covers every case type", not "can't throw".

Unions and System.Text.Json

System.Text.Json in .NET 11 understands unions, with one catch. A union serializes as its current value only, with no type marker. Reading it back works when each case has a different JSON kind, like a number and a string:

Result: 42 / "boom"
  back: int 42 / string boom

When two or more cases are JSON objects (the usual situation with records), deserializing fails:

Shape: {"Radius":2.5}
  JsonException: JSON value type 'Object' is ambiguous for union type 'Shape' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 1.

The fix is a type classifier. .NET 11 ships one that picks the case by the JSON property names, JsonUnionTypeStructuralClassifier. You attach it with [JsonUnion]:

public record Circle(double Radius);
public record Square(double Side);

[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union StructuralShape(Circle, Square);
StructuralShape: {"Side":3}
  back: circle area 19.63
  back: square area 9
  JsonException: The custom type classifier for union type 'StructuralShape' returned null for JSON token type 'StartObject'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.

{"Radius":2.5} became a Circle, {"Side":3} a Square, and an unknown shape ({"Width":3}) failed with a clear error. If two cases have the same property names (for example Cat(string Name) and Dog(string Name)), no structural rule can tell them apart. Then you need your own classifier: derive from JsonTypeClassifierFactory, whose JsonTypeClassifier delegate reads ahead with a Utf8JsonReader and returns the case Type. Or switch to a closed class hierarchy with [JsonPolymorphic] and a $type discriminator, which writes the type into the JSON.

Union Types vs Closed Hierarchies

C# 15 also adds the closed modifier, and the two features look similar in a switch. I compiled a closed record in a class library and used it from an app:

namespace Lib;

public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
using Lib;

GateState state = new Open(40);
Console.WriteLine(Describe(state));
Console.WriteLine(Partial(state));

static string Describe(GateState state) => state switch
{
    Closed => "closed",
    Open(var percent) => $"{percent}% open",
};

static string Partial(GateState state) => state switch
{
    Open(var percent) => $"{percent}% open",
};

Deriving from it in the app project fails, and the switch that skips Closed gets CS8509 like a union does:

## with Jammed : GateState in App
App/Program.cs(18,21): error CS9382: 'Jammed': cannot use a closed type 'GateState' from another assembly as a base type.
## without it
App/Program.cs(13,49): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Lib.Closed' is not covered.
40% open
40% open

How I'd choose:

  • Union when the cases are types that already exist and don't share a base: int or string, a domain type or an error type, types from different libraries. The union is a struct, and value-type cases box.
  • Closed hierarchy when you own all the types and want them to share members and a base type. It's an ordinary class hierarchy: no boxing, null is the only "missing" value, and System.Text.Json's existing [JsonPolymorphic] support writes a type discriminator.

Can You Use Union Types on .NET 10?

Not out of the box. Unions need the C# 15 compiler and two runtime types that first ship in .NET 11. Targeting net10.0 with the .NET 11 SDK gives:

Program.cs(6,14): error CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined or imported
Program.cs(6,14): error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.UnionAttribute..ctor'

With the default language version (C# 14), the compiler doesn't even recognize union as a keyword, and the errors point everywhere except at the cause:

Program.cs(6,1): error CS0106: The modifier 'public' is not valid for this item
Program.cs(6,1): error CS8803: Top-level statements must precede namespace and type declarations.
Program.cs(6,21): error CS1001: Identifier expected
Program.cs(6,26): error CS1001: Identifier expected

If you get CS0106, CS8803 and CS1001 on a union line, your project isn't compiling as C# 15. Check TargetFramework and LangVersion.

Declaring the two missing types yourself makes it compile. The shapes below match what I read from the RC2 runtime with reflection:

// Polyfill so the C# 15 compiler can emit unions on net10.0 (the types ship in the .NET 11 runtime)
namespace System.Runtime.CompilerServices
{
    public interface IUnion
    {
        object? Value { get; }
    }

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
    public sealed class UnionAttribute : Attribute
    {
    }
}
(build succeeded, 0 errors, 0 warnings)

The Pet switch then ran on the installed .NET 10.0.12 runtime and printed Rex. I wouldn't ship it. Preview language features on an older target framework aren't supported, the System.Text.Json union support isn't in .NET 10, and the shapes could still change before the final release. Use it to try the feature, then move to net11.0.

Quick Reference: Union Errors and Fixes

MessageCauseFix
CS8509 ... the pattern 'Bird' is not coveredA case type is missing from the switchAdd the arm; make CS8509 an error
CS8509 ... the pattern '_' is not coveredSwitching on .Value (an object)Switch on the union itself
CS0029: Cannot implicitly convert type 'Fish' to 'Pet'Not a case typeAdd it to the union's case list
CS8121 ... cannot be handled by a pattern of type 'Fish'is or case with a non-case typeRemove the check, or add the case
SwitchExpressionException ... Unmatched value was Petdefault or null union, no warningnull => arm or check Value is null
JSON value type 'Object' is ambiguous for union typeSeveral object-shaped cases[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] or a custom classifier
CS0518: Predefined type '...IUnion' is not definedTarget is older than net11.0Target net11.0
CS0106, CS8803, CS1001 on a union lineLanguage version below C# 15Target net11.0 (C# 15 is the default there)
CS9382 ... cannot use a closed type ... from another assemblyDeriving from a closed classDerive inside the declaring assembly

How I Tested This

Console apps on the .NET 11 RC2 SDK (11.0.100-rc.2.26504.105, runtime 11.0.0-rc.2.26504.105) on Windows 11. Each compiler message comes from a separate build of a one-file variant, and the run output is copied from the console. The type shapes come from reflection over the RC2 runtime. The official references are What's new in C# 15, the union types reference and the closed modifier.

Related posts: C# 14 and C# 13 new features · C# generics: constraints and errors · System.Text.Json vs Newtonsoft · all C# tutorials

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 404 on Page Refresh After Deployment: 5 Server Fixes

Last updated: October 20, 2026 · Tested with Angular CLI 22.2.3 on Node 24.21, ASP.NET Core 10 (runtime 10.0.12), nginx 1.30.6, http-server 14.1.1 and serve 14.2.6, Windows 11 Short answer: your Angular app works until you refresh a page or open a deep link like /products/42 , then the server returns 404 Not Found . The server is looking for a file called products/42 and there isn't one: only the Angular router knows that route. The fix is a fallback rule that returns index.html for every URL that isn't a real file, so Angular can boot and route it: ASP.NET Core: app.MapFallbackToFile("index.html"); nginx: try_files $uri $uri/ /index.html; IIS: a URL Rewrite rule in web.config (and the URL Rewrite module installed) Apache: FallbackResource /index.html I first wrote this post in 2022 with only the IIS rule. For this update I built a fresh Angular 22 app and tested every server below. The basic fix takes one line, but a careless fallback creates three ...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...