Start Debugging

Fix: CS8509 or CS0161 on a switch that is exhaustive over a C# 15 union type

Switch on the union value, not .Value, and use a switch expression or add case null to a switch statement. Statements need null coverage that expressions only warn about.

Switch on the union value itself, not on its .Value property, and prefer a switch expression. If you need a switch statement in a method that returns, add a case null: arm (or a throw after the switch): the compiler only treats a switch statement over a union as complete when the null Value of a default union is covered. All behaviour below was measured on the .NET 11 RC1 SDK (11.0.100-rc.1.26425.128, C# 15, no LangVersion override needed).

The error in context

You declared a union, you matched every case type, and the compiler still says you did not:

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.
error CS0161: 'Pets.Describe(Pet)': not all code paths return a value
error CS0165: Use of unassigned local variable 's'
warning CS8655: The switch expression does not handle some null inputs (it is not exhaustive). For example, the pattern 'null' is not covered.
error CS8780: A variable may not be declared within a 'not' or an 'or' pattern or a union matching involving matching against either the instance, or its underlying value.

They are five different symptoms of the same misunderstanding: union exhaustiveness in C# 15 is a property of union matching, and union matching only kicks in under specific conditions. Step outside those conditions and you are back to plain object pattern matching, where two type patterns are never exhaustive.

Why the compiler does not see your switch as exhaustive

The C# 15 unions specification says it in one line: a union type is assumed to be “exhausted” by its case types, so a switch expression is exhaustive if it handles all of the union’s case types. Everything else follows from the fine print.

  1. The input must be the union value. Union matching only happens “when the input value of a pattern is of a union type or of a nullable of a union type”. Switching on pet.Value (typed object?) or on a union that was boxed into object gives the compiler no case list, so it asks for _.
  2. Switch statements are stricter than switch expressions. A switch expression with an unhandled null compiles with a CS8655 warning. A switch statement used for definite assignment or return-path analysis only counts as complete when null is also covered, so the end of the switch stays reachable and you get CS0161 or CS0165.
  3. A union Value can always be null. public union Pet(Cat, Dog) lowers to a struct with a public object? Value { get; }. default(Pet) holds null, and so does new Pet((Cat)null!). The spec lists this under well-formedness: Value is “null or a value of a case type”.
  4. Type parameters do not unwrap. A case type T in union Result<T>(T, Exception) cannot be pattern-matched with a designation, because the compiler cannot prove whether T v should test the union instance or its contents. That is CS8780.

Minimal repro

// .NET 11 RC1 SDK 11.0.100-rc.1.26425.128, C# 15, <Nullable>enable</Nullable>
public record Cat(string Name);
public record Dog(string Name);
public union Pet(Cat, Dog);
public union MaybePet(Cat?, Dog);
public union Result<T>(T, Exception);

static class Pets
{
    // OK: no diagnostics
    static string A(Pet p) => p switch { Cat c => c.Name, Dog d => d.Name };

    // CS8509: pattern '_' is not covered
    static string B(Pet p) => p.Value switch { Cat c => c.Name, Dog d => d.Name };

    // CS0161: not all code paths return a value
    static string Describe(Pet p)
    {
        switch (p)
        {
            case Cat c: return c.Name;
            case Dog d: return d.Name;
        }
    }

    // CS8655: pattern 'null' is not covered (Cat? is a nullable case type)
    static string D(MaybePet p) => p switch { Cat c => c.Name, Dog d => d.Name };

    // CS8509: a union boxed into object is just an object
    static string E(object o) => o switch { Cat c => c.Name, Dog d => d.Name };

    // CS8655: Nullable<Pet> can be null
    static string F(Pet? p) => p switch { Cat c => c.Name, Dog d => d.Name };

    // CS8780 on 'TV v'
    static string I<TV>(Result<TV> r) => r switch { TV v => v!.ToString()!, Exception e => e.Message };
}

Method A is the reference point: the union value goes straight into a switch expression, every case type has an arm, and the compiler is silent. Every other method breaks one of the four rules above.

Fix, in detail

Work through these in order. The first one fixes most real-world reports.

1. Match the union, not .Value

Value is declared as object?. The moment you dereference it, you have thrown away the union type and its case list. Pattern matching on the union already unwraps the contents for you: p is Cat c is compiled as a test on p.Value, so there is no reason to reach for .Value yourself.

// .NET 11 RC1, C# 15
// Before: CS8509
static string Name(Pet p) => p.Value switch { Cat c => c.Name, Dog d => d.Name };

// After: exhaustive, no default arm
static string Name(Pet p) => p switch { Cat c => c.Name, Dog d => d.Name };

The same applies to a union that travels through an object, IUnion, or a generic T parameter. Per the spec’s resolved question, a type parameter is never a union type, even when constrained to one. In RC1, static string L<TU>(TU u) where TU : struct, IUnion => u switch { Cat c => ..., Dog d => ... } does not even get to exhaustiveness: it fails with CS8121, “An expression of type ‘TU’ cannot be handled by a pattern of type ‘Cat’”. Keep the concrete union type in the signature.

One partial exception: property patterns on Value do pick up union knowledge in RC1. r switch { { Value: TV v } => ..., { Value: Exception e } => ... } produced only CS8655, not CS8509. The spec still lists “Should direct Value property matching follow Union rules?” as an open question, so do not build on it.

2. Prefer a switch expression; give a switch statement a case null

This is the CS0161 / CS0165 case and the most surprising one, because the identical arms work as an expression. I measured three variants on RC1:

// .NET 11 RC1, C# 15
public union Pet(Cat, Dog);
public closed class Shape;
public sealed class Sq : Shape;
public sealed class Ci : Shape;

// error CS0161
static string S1(Pet p) { switch (p) { case Cat c: return c.Name; case Dog d: return d.Name; } }

// compiles
static string S2(Pet p) { switch (p) { case Cat c: return c.Name; case Dog d: return d.Name; case null: return "none"; } }

// compiles: bool is exhaustive for statements
static int S3(bool b) { switch (b) { case true: return 1; case false: return 0; } }

// error CS0161: closed hierarchies behave like unions here
static int S4(Shape s) { switch (s) { case Sq: return 1; case Ci: return 0; } }

S3 proves the compiler does do exhaustiveness for switch statements. What it does not do is ignore an uncovered null. A switch expression downgrades the missing null to a nullable warning (and, for a union whose case types are all non-nullable, to nothing at all). A switch statement uses the full exhaustiveness answer for reachability, and that answer includes null. Turning #nullable disable on does not change it: the RC1 compiler still reports CS0161 for both the union and the closed class.

You have three clean fixes, in order of preference:

// .NET 11 RC1, C# 15
using System.Diagnostics;

// a) Use an expression. Most switch statements that only return can be one.
static string Describe(Pet p) => p switch { Cat c => c.Name, Dog d => d.Name };

// b) Cover null explicitly. Use this when a default union is a legitimate state.
static string Describe2(Pet p)
{
    switch (p)
    {
        case Cat c: return c.Name;
        case Dog d: return d.Name;
        case null: return "no pet";
    }
}

// c) Keep the statement and declare the end unreachable.
static string Describe3(Pet p)
{
    switch (p)
    {
        case Cat c: return c.Name;
        case Dog d: return d.Name;
    }
    throw new UnreachableException();
}

Avoid default: as the fix. It compiles, but it also swallows the next case type you add to the union, which is the one diagnostic you actually wanted to keep.

The CS0165 variant is the same issue in assignment form: string s; switch (p) { case Cat c: s = ...; break; case Dog d: s = ...; break; } return s; fails because the end of the switch is reachable with s unassigned. The same three fixes apply.

3. Handle null where the type says it can be null

CS8655 is the compiler being right. You get it in two situations:

Add a null arm. On a union, null matches both a null instance and a union whose Value is null:

// .NET 11 RC1, C# 15
static string D(MaybePet p) => p switch
{
    Cat c => c.Name,
    Dog d => d.Name,
    null => "none",
};

If the nullable case type was accidental, remove the ? from the union declaration instead.

4. Drop the designation on type-parameter case types

For union Result<T>(T, Exception) the arm T v fails with CS8780 regardless of constraint. I tried unconstrained, where T : notnull, where T : class, and where T : struct: all four report CS8780 on RC1. What works is a type pattern without a variable, or catching the generic case last with var:

// .NET 11 RC1, C# 15
public union Result<T>(T, Exception);

// Exhaustive and clean with notnull: prints "v:5" for new Result<int>(5)
static string W4<TV>(Result<TV> r) where TV : notnull =>
    r switch { TV => "v:" + r.Value, Exception => "e" };

// Match the concrete case first, let var take the rest
static string W3<TV>(Result<TV> r) =>
    r switch { Exception e => e.Message, var other => other.Value!.ToString()! };

Without the notnull constraint, the TV => form still compiles but adds CS8655, because TV could be a nullable type. Note that var does not unwrap a union: other is the Result<TV>, not its contents, which is why you read .Value from it.

The runtime half: a default union still throws

Silencing the compiler is not the same as handling every value. A union whose case types are all non-nullable gets no warning when you switch over default:

// .NET 11 RC1, C# 15
public union Result2(int, Exception);

static string Show(Result2 r) => r switch { int v => v.ToString(), Exception e => e.Message };

Show(default); // System.Runtime.CompilerServices.SwitchExpressionException at runtime

The spec’s resolved question “Default nullable state of Value property” acknowledges that default(U).Value is null while the nullable analysis only looks at the case types. In practice, a default union appears when it is a field that was never assigned, an array element, a default return in generic code, or a deserialized payload that did not populate the union. If any of those paths exist, add the null arm even though the compiler is not asking for one, or validate at the boundary. The same thinking applies to non-nullable properties that are never set in a constructor: the annotation describes intent, not a runtime guarantee.

Gotchas and lookalikes

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back