C# 15 - Closed hierarchies

C# logo

‘Hierarchy’ is one of those words that for some reason I need to stop and think about how it is spelled - does it follow the “i after e except after c” or is it an exception? I think I got it right this time.

C# logo

So enough of spelling, let’s look at another new language feature introduced in C# 15 - Closed Hierarchies. By using the closed modifier, we can now indicate that a class (including record classes) is a “closed hierarchy”. Given this closed type:

public closed record Season
{
    public virtual int Rainfall { get; set; }
}

We can then declare other subtypes of that class in the same assembly in the usual way:

public record Winter : Season;
public record Summer(int BushfireRisk) : Season;
public record Spring(int PollenCount) : Season;
public record Autumn : Season;

If we use these types in a switch expression, then the compiler knows all the possible types and can tell if the expression covers all the direct descendants.

static string SummariseSeason(Season season) => season switch
{
    Summer s => $"It's summer. Bushfire alert level {s.BushfireRisk}",
    Winter w => "It's winter",
    Spring sp => $"It's spring. Pollen count {sp.PollenCount}",
    Autumn a => "It's autumn",
};

The above code doesn’t need a default case. Creating a Summer instance of Season and calling that method like this:

Season season = new Summer(5);
Console.WriteLine(SummariseSeason(season));

Outputs: It's summer. Bushfire alert level 5

Note that if Season was nullable, then you would need to handle the null case.

Implications of closed types

You can’t inherit from that type in another assembly

If this code was declared in a different assembly to the closed type, it will not compile:

// Invalid code
public record ChickenSalt : closed_base.Season
{
}

Fails with compiler error:

error CS9382: 'ChickenSalt': cannot use a closed type 'Season' from another assembly as a base type.

The closed class is implicitly abstract

You can’t instantiate a closed type directly:

// Invalid code
var class1 = new Season();

Fails with:

error CS0144: Cannot create an instance of the abstract type or interface 'Season'

Closed is different to sealed

The sealed modifier can be applied to a class, or a method or property that overrides a base class’s virtual method or property.

Once something is sealed, it cannot be inherited from or overridden in a subtype.

A subtype of a closed type defaults to being open

A subtype of a closed type is not also closed automatically. This means you can inherit from the subtype in another assembly. This code is valid if it was in another assembly:

public record BushfireRisk : closed_base.Summer
{
}

A subtype can be closed only if you also add the closed modifier to it.

The compiler will warn you about incomplete switch expressions

If you had the following code:

static string IncompleteSummariseSeason(Season season) => season switch
{
    Summer s => $"It's summer. Bushfire alert level {s.BushfireRisk}",
    Winter w => "It's winter",
    Spring sp => $"It's spring. Pollen count {sp.PollenCount}"
};

The compiler will emit the following warning:

warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'closed_base.Autumn' is not covered

Using closed can be a breaking change

Adding closed to an existing class makes it implicitly abstract. This could break code that instantiates it or inherits from it in another assembly.

Adding a new subtype to an existing closed hierarchy could also impact downstream consumers; For example, any existing switch expressions they had could now be non-exhaustive and will emit compiler warnings.

Conclusion

Using closed hierarchies allows the compiler to reason about switch expressions and generate warnings if you’ve missed a case.

Full code samples can be found in this repo.

Also, I think after writing this post, I’m getting better at spelling ‘hierarchy’ 😀

C# 15 - Union types

This is something some people have been wanting in C# for a really long time.

Up until now, if you wanted an object that could be one of a limited number of types, you might have resorted to inheritance like this:

public abstract record Pet;
public record Dog : Pet;
public record Cat : Pet;

The downside of this is that you’re implying there’s a hierarchy.

Union types solve this problem. Here are a couple of union types for Drill and Tool.

public enum HammerType
{
    Claw,
    Sledge
}
public record Hammer(HammerType Type);

public enum ScrewdriverType
{
    Flathead,
    Phillips
}
public record Screwdriver(ScrewdriverType Type);

public record PowerDrill(bool Hammer, bool Reversible, bool Cordless);
public record ManualDrill();

public union Drill(PowerDrill, ManualDrill);

public union Tool(Hammer, Screwdriver, Drill);

A Drill can be either PowerDrill or ManualDrill. They don’t have any common properties so there’s no need to make one inherit from the other, or have a common base class.

Using union types is straightforward:

Tool myTool = new Drill(new PowerDrill(true, true, false));

switch (myTool)
{
    case Hammer h:
        Console.WriteLine($"Hammer: {h.Type}");
        break;
    case Screwdriver s:
        Console.WriteLine($"Screwdriver: {s.Type}");
        break;
    case Drill d:
        switch (d)
        {
            case PowerDrill pd:
                Console.WriteLine($"PowerDrill: Hammer={pd.Hammer}, Reversible={pd.Reversible}, Cordless={pd.Cordless}");
                break;
            case ManualDrill md:
                Console.WriteLine("ManualDrill");
                break;
        }
        break;
}

Note that the Tool type doesn’t directly include all the types that make up the Drill type.

If you use a switch expression with a union type and don’t cover all possible types, the compiler can detect this and generate a warning:

var t = myTool switch
{
    Hammer h => $"Hammer: {h.Type}",
    Screwdriver s => $"Screwdriver: {s.Type}",
    Drill d => d switch
    {
        PowerDrill pd => $"PowerDrill: Hammer={pd.Hammer}, Reversible={pd.Reversible}, Cordless={pd.Cordless}",
        //ManualDrill md => "ManualDrill"
    }
};

Causes this warning:

warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'ManualDrill' is not covered

Kinds of Union types

Union types exist in a number of programming languages, and there are apparently two kinds - “tagged” (also known as discriminated) and “untagged”.

I did a bit of reading up to understand the difference:

Untagged unions

This is what C# 15 provides. C# union types are “unions of types”.

You check the type to identify what it is.

Tagged or discriminated unions

F# has these.

type ApiResult =
    | Success of body: string
    | Failure of statusCode: int * message: string

type PersonName =
    | FirstName of string
    | LastName of string

Note how a discriminated union allows you to define a type which is either of two strings. The ‘discriminator’ (or ‘tag’) is separate to the value’s type.

Conclusion

I think union types provide the opportunity to be much more expressive and precise when defining the shape of the data structures you’re working with in C#. It also means you can ‘lean on the compiler’ to ensure a union type’s possible values are all handled correctly.

More recent posts

Older posts

All posts (currently 1200) can be found in the archive. Jump to recent years in 2026 , 2025 and 2024 .