C# 15 Union Types and Closed Hierarchies: Real Sum Types and Exhaustive Pattern Matching
C# 15 union types combine independent types into a closed set, and the closed modifier limits the direct subtypes of a class hierarchy to its assembly; in both cases the compiler names the missing case. We turn Bookstore's OrderStatus into a closed hierarchy and Result and its error codes into unions, and show how System.Text.Json serializes each, how they compare with TypeScript, and the binary breaking change risk in a public API, all with real output.
Version note: The C# 15 code in this post was built and run with the .NET 11 SDK (11.0.100) on net11.0; the C# 14 output mentioned for comparison with .NET 10 (SDK 10.0.402); the TypeScript examples with TypeScript 7.0.2.
Guess First: Adding a Case to a Closed Hierarchy from Outside
This time the order status of Bookstore, the online bookstore used as the shared example across this site's series, is written with C# 15's closed modifier and lives in the Bookstore.Ordering assembly. Note that Cancelled isn't sealed.
namespace Bookstore.Ordering;
public closed record OrderStatus;
public sealed record Pending : OrderStatus;
public sealed record Paid(string PaymentId) : OrderStatus;
public sealed record Shipped(string PaymentId, string TrackingNo) : OrderStatus;
public record Cancelled(string Reason) : OrderStatus;
An application in another assembly uses these types; it forgot the Cancelled arm in a switch and declared two new records:
using Bookstore.Ordering;
Console.WriteLine(Short(new Paid("PAY-1001")));
static string Short(OrderStatus status) => status switch
{
Pending => "P",
Paid => "$",
Shipped => "S"
};
record Refunded(string Reason) : OrderStatus;
record CancelledByWarehouse(string Reason) : Cancelled(Reason);
Which lines compile? What does the compiler say about the forgotten arm?
The Answer: closed Only Locks the Direct Subtypes
Program.cs(12,8): error CS9382: 'Refunded': cannot use a closed type 'OrderStatus' from another assembly as a base type.
Refunded doesn't compile: deriving directly from a closed class is only possible in the assembly that declares it. This is the lock we tried to build in the fifth post with an abstract record and a private constructor but couldn't close completely because of the copy constructor. Delete the Refunded line and a warning is left:
Program.cs(5,51): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Ordering.Cancelled' is not covered.
For the same switch in C# 14 the compiler could only say "the _ pattern isn't handled"; now it names the missing case. CancelledByWarehouse, on the other hand, compiles without complaint. As the closed modifier page on Microsoft Learn also points out, the restriction isn't transitive: the direct subtypes of OrderStatus are locked, but anyone can derive from Cancelled, which isn't sealed. That doesn't break exhaustiveness, because CancelledByWarehouse is a Cancelled and the Cancelled arm catches it; but if you want a closed set under Cancelled too, you have to make it sealed or closed. Writing the cases as sealed from the start is a good default. This two-assembly example and the other compile-error and binary compatibility experiments in this post were run in separate test projects, to keep the project in the snapshot buildable.
closed Hierarchies: the Missing Piece of abstract record
This is a version post: the series is based on C# 14 and .NET 10, while the code here is compiled in a separate project (csharp15/, pinned to the .NET 11 SDK with its own global.json). C# 15 and .NET 11 shipped on 10 November 2026. The "What's new in C# 15" page on Microsoft Learn (dated 10 November 2026) lists union types and closed hierarchies as features available with the default language version in projects targeting net11.0, without the preview setting; everything in this post was run with that GA SDK.
Rewriting the fifth post's OrderStatus with closed is a one-word change:
namespace Bookstore.Fp.CSharp15.Closed;
// Only this assembly can add direct cases; closed implies abstract
public closed record OrderStatus;
public sealed record Pending : OrderStatus;
public sealed record Paid(string PaymentId) : OrderStatus;
public sealed record Shipped(string PaymentId, string TrackingNo) : OrderStatus;
public sealed record Cancelled(string Reason) : OrderStatus;
public static class OrderWorkflow
{
public static string Describe(OrderStatus status) => status switch
{
Pending => "pending",
Paid paid => $"paid with {paid.PaymentId}",
Shipped shipped => $"shipped with {shipped.TrackingNo}",
Cancelled cancelled => $"cancelled: {cancelled.Reason}"
};
}
$ dotnet run --project Bookstore.Fp.CSharp15 -- 08-closed
pending
paid with PAY-1001
shipped with TRK-778
cancelled: customer request
Describe has no _ => throw new UnreachableException(...) arm and the compiler doesn't warn. The private constructor trick isn't needed any more either; the cases don't have to be nested. The rules from the documentation in short: a closed class is implicitly abstract and can't be combined with sealed, static or an explicit abstract; it applies only to classes (including record classes); a generic case can't declare a type parameter it doesn't use in its base class (otherwise the compiler couldn't work out the possible subtypes). closed is a contextual keyword, so existing variables called closed keep working.
The real value of closed is that the hierarchy stays a class hierarchy: the cases can share properties and methods, and with expressions, record equality and positional patterns work as before. The fifth post's code became exhaustive with almost no changes.
union: Combining Cases Without Inheritance
We saw the basic union syntax and subsets such as Cancellable(Pending, Paid) in the fifth post's "With C# 15: union Types" section. Here let's look at what a union gains by not using inheritance. The case types can know nothing about each other: int, string, a type from another library, even a generic. Let's write the previous post's Result as a union:
public sealed record Ok<T>(T Value);
public sealed record Failed(IReadOnlyList<OrderError> Errors);
public union Result<T>(Ok<T>, Failed)
{
public Result<TOut> Map<TOut>(Func<T, TOut> map) => this switch
{
Ok<T>(var value) => new Ok<TOut>(map(value)),
Failed failed => failed,
null => throw new InvalidOperationException("default(Result<T>) has no value.")
};
public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> bind) => this switch
{
Ok<T>(var value) => bind(value),
Failed failed => failed,
null => throw new InvalidOperationException("default(Result<T>) has no value.")
};
}
Three details stand out. In the Failed failed => failed arm there's an implicit conversion from Failed to Result<TOut>; the previous post's Result<TOut>.Failure(Errors) conversion happens by itself, because Failed is a case of every Result<TOut>. You can add methods to a union's body (but not instance fields or auto-properties). And the null arm: the union the compiler generates is always a struct, and the Value of its default is null. In the previous post we made Result a class precisely for this reason; with a union the same question comes back. The documentation also shows how to write a class-based union by hand with the [Union] attribute, but then you give up the short syntax the compiler generates.
Let's model the error side as a union instead of string codes too; the last exercise of the previous post asked for exactly this:
public sealed record EmailInvalid(string Email);
public sealed record BookNotFound(string Isbn);
public sealed record StockInsufficient(string Isbn, int Left);
public union OrderError(EmailInvalid, BookNotFound, StockInsufficient);
public static class OrderErrors
{
public static int StatusCode(OrderError error) => error switch
{
EmailInvalid => 400,
BookNotFound => 404,
StockInsufficient => 409
};
public static string Message(OrderError error) => error switch
{
EmailInvalid(var email) => $"'{email}' is not a valid email address.",
BookNotFound(var isbn) => $"No book with ISBN {isbn}.",
StockInsufficient(var isbn, var left) => $"Only {left} left for {isbn}."
};
}
In the previous post's StatusByCode dictionary, a typo such as "stock.insuficient" silently fell back to 400. Here, when you add a fourth case to OrderError, the switches in StatusCode and Message produce a CS8509 warning naming the missing case; forgetting the HTTP mapping is now a compiler warning. Using it:
Result<string> result = FindStock(stock, isbn)
.Bind<int>(left => left >= quantity ? new Ok<int>(left - quantity) : new Failed([new StockInsufficient(isbn, left)]))
.Map(after => $"{quantity} x {isbn} reserved, {after} left");
Console.WriteLine(result switch
{
Ok<string>(var text) => $"ok: {text}",
Failed(var errors) => $"failed: {string.Join(", ", errors.Select(e => $"{OrderErrors.StatusCode(e)} {OrderErrors.Message(e)}"))}",
null => "default"
});
$ dotnet run --project Bookstore.Fp.CSharp15 -- 08-result
ok: 1 x 9780441013593 reserved, 0 left
failed: 409 Only 1 left for 9780441013593.
failed: 404 No book with ISBN 9780261103344.
default: True
The last line shows that Value is null for Result<int> empty = default;. Note also the explicit type argument in Bind<int>. The lambda returns Ok<int> in one branch and Failed in the other; these two types have no common type and the union conversion doesn't help type inference. Delete the <int> and you get:
Post08.cs(24,18): error CS0411: The type arguments for method 'Result<int>.Bind<TOut>(Func<int, Result<TOut>>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.
We didn't run into this in the previous post because the lambdas we passed to Bind called functions whose return type was already Result<T>; the implicit operators of the class-based Result don't help type inference either. The fix is the same with a union: small functions that state their return type (like FindStock) or an explicit type argument.
Comparing with TypeScript Discriminated Unions
In TypeScript, sum types have been part of the language for years and match both of the tools C# 15 brings. The "order that hasn't shipped" subset is derived from the existing union with Extract:
// Only orders that haven't shipped can be cancelled
export type Cancellable = Extract<OrderStatus, { kind: "pending" | "paid" }>;
export function cancel(status: Cancellable, reason: string): OrderStatus {
return { kind: "cancelled", reason };
}
import { cancel, type OrderStatus } from "../src/order-status.ts";
const shipped: OrderStatus = { kind: "shipped", paymentId: "PAY-1001", trackingNo: "TRK-778" };
cancel(shipped, "too late");
$ npx tsc -p tsconfig.errors.json
errors/08-cancellable-errors.ts(4,8): error TS2345: Argument of type '{ readonly kind: "shipped"; readonly paymentId: string; readonly trackingNo: string; }' is not assignable to parameter of type 'Cancellable'.
| Topic | C# 15 union | C# 15 closed hierarchy | TypeScript discriminated union |
|---|---|---|---|
| Relationship between cases | none; cases are independent types | common base class | common literal property (kind) |
| Exhaustiveness | compiler, CS8509 warning | compiler, CS8509 warning | with never, as an error |
| Runtime shape | a struct whose Value is object? | class hierarchy | plain JavaScript object |
| Defining a subset | a new union, e.g. Cancellable | a new closed intermediate class | Extract from the existing union |
| Case information in JSON | not written | written with a setting | the kind property is already part of the data |
The biggest difference is the last row. In TypeScript the discriminator is the data itself; in C# the case information lives in the type and writing it to JSON is a separate decision. The next section is about the consequences.
Which When: union, closed or a Classic Hierarchy?
The three tools are close to each other but answer different questions:
- union: when the cases don't or can't share a base class (types from another library, primitives like
intandstring), or when the same types belong to different sets (OrderStatusandCancellable). The cost: adefaultvalue because it's a struct, boxing for value type cases, and case information lost in JSON. - closed hierarchy: when the cases share data or behaviour, when record equality and
withexpressions matter, when the types already form a hierarchy. It's the way to make an existing abstract record hierarchy exhaustive with the fewest changes. - Classic, open hierarchy: when other assemblies adding new cases is a real requirement (plugins, framework extension points). Then you give up exhaustiveness and the
_arm comes back.
The "Use C# unions and closed hierarchies in ASP.NET Core" post published on the .NET blog in September 2026 draws a similar line: a closed hierarchy with a discriminator when you design a new API with classes you control, a union when you model an existing contract without a discriminator or primitives.
Serialization: What Does System.Text.Json Write?
System.Text.Json in .NET 11 recognises both constructs, but their default behaviours are very different. Let's serialize the same three statuses as a union, as a closed hierarchy and as a closed hierarchy with InferClosedTypePolymorphism:
OrderStatus[] statuses = [new Pending(), new Paid("PAY-1001"), new Cancelled("customer request")];
string unionJson = JsonSerializer.Serialize(statuses, Web);
Console.WriteLine($"union: {unionJson}");
try
{
OrderStatus[] back = JsonSerializer.Deserialize<OrderStatus[]>(unionJson, Web)!;
Console.WriteLine($"union back: {string.Join(", ", back.Select(s => s.Value?.GetType().Name ?? "null"))}");
}
catch (Exception ex)
{
Console.WriteLine($"union back: {ex.GetType().Name}: {ex.Message}");
}
Closed.OrderStatus[] closed = [new Closed.Pending(), new Closed.Paid("PAY-1001"), new Closed.Cancelled("customer request")];
Console.WriteLine($"closed: {JsonSerializer.Serialize(closed, Web)}");
JsonSerializerOptions inferred = new(Web) { InferClosedTypePolymorphism = true };
string closedJson = JsonSerializer.Serialize(closed, inferred);
Console.WriteLine($"closed + infer: {closedJson}");
Closed.OrderStatus[] closedBack = JsonSerializer.Deserialize<Closed.OrderStatus[]>(closedJson, inferred)!;
Console.WriteLine($"closed back: {closedBack.SequenceEqual(closed)}");
$ dotnet run --project Bookstore.Fp.CSharp15 -- 08-json
union: [{},{"paymentId":"PAY-1001"},{"reason":"customer request"}]
union back: JsonException: JSON value type 'Object' is ambiguous for union type 'Bookstore.Fp.CSharp15.OrderStatus' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $[0] | LineNumber: 0 | BytePositionInLine: 2.
closed: [{},{},{}]
closed + infer: [{"$type":"Pending"},{"$type":"Paid","paymentId":"PAY-1001"},{"$type":"Cancelled","reason":"customer request"}]
closed back: True
The union writes the active case in its own JSON shape, without any wrapper or discriminator. The result is readable, but when reading it back all three cases are JSON objects and the serializer can't tell which one to pick. The closed hierarchy is serialized through the declared type (OrderStatus) by default, and since that type has no properties, three empty objects come out: the payment and cancellation details disappear without even an exception. InferClosedTypePolymorphism (in the options or on the [JsonPolymorphic] attribute) derives all subtypes from the list the compiler knows, writes a $type discriminator and the round trip works.
The fix for the union is a type classifier. In a separate test project we added the [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] attribute to the union; this classifier tells cases apart by their property names. It worked for Paid and Cancelled, but as long as Pending, which has no properties, was in the union, the first use threw this exception:
Unhandled exception. System.NotSupportedException: The JsonUnionTypeStructuralClassifier cannot classify union type 'Status' because the case type 'Pending' can never be selected uniquely. The case type 'Paid' recognizes every property name recognized by 'Pending' and has no stricter required-property or unmapped-member constraints.
The practical conclusion: in an outward-facing contract (an API, a queue message) where you want the case information to be explicit in the JSON, a closed hierarchy with a discriminator is the safer choice. Note also that the $type values are CLR type names: renaming the class changes the wire format too. If you want to speak the same kind values as the TypeScript side, giving the names explicitly with [JsonDerivedType(typeof(Pending), "pending")], as in the fifth post, is still the most robust way.
The Database: EF Core and These Types
This section wasn't run; it's based on the documentation. The "What's new" page of Entity Framework Core 11 (dated 3 August 2026 on the day of writing) doesn't mention union types or closed hierarchies, and we didn't try them with EF Core. A closed record hierarchy is, as far as the CLR is concerned, an ordinary abstract base class with subtypes; how the TPH, TPT and TPC inheritance mapping strategies handle it follows the rules in the EF Core series post "Inheritance Mapping in EF Core: TPH, TPT and When to Choose Which". A union, however, is a struct whose Value is object?, and we couldn't find a documented way to map it to a column or an entity.
The split we suggested in the fifth post makes even more sense here: let the database row hold a status column and nullable columns for the case data, and build the sum type in the repository or mapping layer. The domain model becomes exhaustive with C# 15's types, and the database schema stays in a shape EF Core understands. When EF Core support arrives (or if it already has), the first step should be trying it in an integration test against your own schema.
Library Versioning, LangVersion and Breaking Changes
Union and closed need types from the runtime as well as the compiler. If you compile a library for both .NET 10 and .NET 11 you'll see this immediately. Declaring a union and a closed record in a project targeting net10.0 with <LangVersion>15</LangVersion> gives:
OrderStatus.cs(6,14): error CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined or imported
OrderStatus.cs(6,14): error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.UnionAttribute..ctor'
OrderStatus.cs(8,22): error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.IsClosedTypeAttribute..ctor'
IUnion, UnionAttribute and IsClosedTypeAttribute come with the .NET 11 runtime. The default language version for net10.0 is C# 14 anyway, and there union isn't even a keyword. In a multi-targeted library you have to put these types only in the net11.0 target, or wait until you drop .NET 10 support.
Turning an existing public API into a union is sneakier. Picture a library that uses an abstract record hierarchy like the one in the fifth post, with an application compiled against it. In the library's new version OrderStatus becomes a union, and the signature of OrderText.Describe(OrderStatus) stays the same in the source. Copy only the new DLL without recompiling the application:
namespace Bookstore.Ordering;
public sealed record Pending;
public sealed record Paid(string PaymentId);
public union OrderStatus(Pending, Paid);
public static class OrderText
{
public static string Describe(OrderStatus status) => status switch
{
Pending => "pending",
Paid paid => $"paid with {paid.PaymentId}",
null => "unknown"
};
}
$ dotnet App/bin/Debug/net11.0/App.dll
paid with PAY-1001
$ cp Lib/bin/Debug/net11.0/Bookstore.Ordering.dll App/bin/Debug/net11.0/
$ dotnet App/bin/Debug/net11.0/App.dll
Unhandled exception. System.MissingMethodException: Method not found: 'System.String Bookstore.Ordering.OrderText.Describe(Bookstore.Ordering.OrderStatus)'.
The type's name is the same, but it went from a class to a struct and the method signature changed at the binary level; every caller that isn't recompiled blows up at run time. Recompiling the application (dotnet build) makes the same code work, so in this example source compatibility holds; but nested type names such as status is OrderStatus.Paid, code that compares OrderStatus with null, or code that looks at GetType() break on recompilation too. For a library, that means a major version.
Then there's the effect of adding a case. The value of an exhaustive switch is that it warns when a new case is added. But that warning shows up in the code of the library's consumers: for a consumer with TreatWarningsAsErrors on, adding a case to a public union or closed hierarchy breaks the build, and for a consumer that isn't recompiled it means a SwitchExpressionException. Treat adding a case to a sum type in a public API as a breaking change.
Common Pitfalls
Serializing a closed hierarchy without a discriminator. The [{},{},{}] line in the output is the most dangerous result: no error, no data. Wherever you serialize through a closed base type, verify InferClosedTypePolymorphism or the [JsonDerivedType] attributes with a test.
Forgetting a union's default value. The Value of a union in an array's elements, in a class's field, or created with default is null. When the case types aren't nullable, the compiler doesn't always warn about a missing null arm in this situation; the switch throws SwitchExpressionException when it receives that value. Where the default value can reach a switch, write a null arm; don't write a _ arm, because it also silences the warning about new cases.
Overlooking boxing for value type cases. The union the compiler generates stores value type cases such as int in an object?; every value is a heap allocation. If you use a type like union Amount(int, decimal) in a loop on a hot path, consider writing a union by hand with the non-boxing access pattern from the documentation (HasValue, TryGetValue).
Assuming closed is transitive. As with CancelledByWarehouse in the guess, any assembly can add new types under a case that isn't sealed. Write the cases as sealed; if you really need a further split, make the intermediate type closed too.
Turning every abstract hierarchy into a union. A union brings no inheritance and no shared behaviour; if the cases have common properties, you'll have to read each of them with a switch. If you already have a record hierarchy, add closed first; keep unions for places where you need to combine types that are independent of each other.
A Small Experiment
★☆☆ Add a Delivered(string PaymentId, string TrackingNo, DateTimeOffset DeliveredAt) case to Closed.OrderStatus. Which switches warn? If you did the same exercise with C# 14 in the fifth post, compare the two compilers' output.
★★☆ Add [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] and a [JsonDerivedType] for each case to Closed.OrderStatus and produce JSON that the fifth post's TypeScript describe function can read. Compared with InferClosedTypePolymorphism, which one copes better with renaming a class?
★★☆ Add a QuantityRange(int Quantity) case to the OrderError union and read the warnings in StatusCode and Message. Then add a _ => 500 arm to StatusCode and add yet another case; what happens to the warning?
★★★ Replace the class-based Result<T> from the previous post with the Result<T> union and rewrite OrderPlacement.PlaceAsync. Is there a way for default(Result<T>) to get into the flow? Which design would you prefer in a public library?
In the last post of the series we'll step back and look at the cost of all these tools: the allocations of closures and immutable copies, the difference between LINQ and a loop and between a struct and a class Option as measured with BenchmarkDotNet, debugging deep composition, and which functional ideas always pay off in a team and which are debatable. We'll also put the first post's LegacyCheckout side by side with where it ended up by the end of the series.