A Pattern Selection Guide and C# 15: Union Types, Closed Hierarchies and How Patterns Evolve
The way to choose the right pattern is to start from the problem, not the pattern: find what must stay free to vary, then pick a candidate from the creation, wrapping, behavior, state, tree and outcome families. How lambdas, yield, records and pattern matching slimmed patterns down in C#, and how C# 15's union types and closed hierarchies rewrite Visitor, State and Result with exhaustive switches, with real compiler warnings.
Version note: The code in this post was built and run with C# 15 and .NET 11; the one example that uses only pre-C# 15 features gives the same output with C# 14 and .NET 10.
Guess First
In the series' Visitor post, Quote had not been added to the switch expression that turned document elements into HTML (HyperText Markup Language). The compiler warned with CS8509 but couldn't say that Quote was the missing piece; it said "the pattern _ is not covered", since other assemblies could also derive from an abstract record. C# 15 and .NET 11 reached GA (General Availability) on November 10, 2026. We change a single word in the same code: closed instead of abstract.
DocumentElement[] invoice =
[
new Paragraph("Order #1042"),
new Image("logo.png", 120),
new Quote("Dune is a masterpiece.", "Grace"),
];
foreach (DocumentElement element in invoice)
{
Console.WriteLine(Html.Render(element));
}
// The hierarchy from the Visitor post; the only change is closed instead of abstract
public closed record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
public sealed record Quote(string Text, string Author) : DocumentElement;
public static class Html
{
public static string Render(DocumentElement element) => element switch
{
Paragraph p => $"<p>{p.Text}</p>",
Table t => $"<table>{t.Rows.Length} rows</table>",
Image { WidthPx: > 600 } i => $"<img src=\"{i.Path}\" class=\"wide\">",
Image i => $"<img src=\"{i.Path}\" width=\"{i.WidthPx}\">",
};
}
Two questions. Does the compiler still report CS8509, and if so, what does it say this time? When the program runs, what happens on the third line, the Quote?
The Compiler Now Names the Missing Type
The build output:
Program.cs(22,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Quote' is not covered.
Running it:
<p>Order #1042</p>
<img src="logo.png" width="120">
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Quote { Text = Dune is a masterpiece., Author = Grace }.
The warning is still CS8509, but it no longer says "the pattern _"; it says "the pattern Quote is not covered". closed says that the direct subtypes of DocumentElement are exactly the ones in this assembly; with a closed list, the compiler can name the missing arm.
The second answer shows what C# 15 did not change: this is a warning, not an error. The program compiled, ran and still threw SwitchExpressionException when it reached Quote. Once we add the missing arm, the warning goes away and we don't need a _ arm either:
// The single arm added to Html.Render
Quote q => $"<blockquote>{q.Text} ({q.Author})</blockquote>",
<p>Order #1042</p>
<img src="logo.png" width="120">
<blockquote>Dune is a masterpiece. (Grace)</blockquote>
The guarantee Visitor has been selling for years, "when a new type is added, every operation breaks at compile time", C# 15 now gives to the switch expression as well. In this final post we first pull the selection guide together, then rewrite Visitor, State and Result with union and closed.
From Problem to Pattern: A Selection Guide

The most common mistake in choosing a pattern is starting from the pattern: a design that begins with "let's use a Strategy here" starts bending the problem to fit it. In its first chapter, the GoF (Gang of Four: the 1994 Design Patterns book by Gamma, Helm, Johnson and Vlissides) suggests several ways to select a pattern; the most useful one, in my experience, is to ask what in the design should stay free to vary. The map is ordered by that question: the problem on the left, the candidate pattern on the right.
In the first three families the questions are, in order: which class do I create, when and in how many steps; what am I putting around an existing object; which code runs under which condition? Look at what the language gives you first: object initializers and required make most Builders unnecessary, record and with make most Prototypes unnecessary, and "exactly one instance" is a lifetime in the DI (Dependency Injection) container. What separates Adapter, Decorator, Proxy and Facade is intent, not structure.
The last three families are the subject of this post. In the state family, behavior depends on the object's current state (State) or you need to return to an earlier state (Memento). In the tree family you build a structure (Composite), run many operations over it (Visitor) or walk it (Iterator). The outcome family comes from outside the GoF: carrying an expected business failure (Result), giving "nothing" a behavior (Null Object). The three green entries are the ones C# 15 changes the most.
How the Language Slimmed Patterns Down
In the first post of the series we discussed Peter Norvig's 1996 observation: as a language gets more powerful, some patterns turn into a language feature and become invisible. The program below shows today's form of five patterns from separate posts in this series, in a single file:
Book dune = new("Dune", 12.99m, Stock: 4);
Book[] catalog = [dune, new("Emma", 7.50m, Stock: 0), new("Persuasion", 9.90m, Stock: 1)];
// Strategy (C# 3): a Func instead of an interface and a family of classes
Func<decimal, decimal> studentPrice = price => Math.Round(price * 0.85m, 2);
// Command (C# 3): lambdas queued now and run later
Queue<Action> outbox = new();
outbox.Enqueue(() => Console.WriteLine("email: order 1042 shipped"));
// Iterator (C# 2): lazy traversal with yield return
foreach (Book book in Catalog.InStock(catalog))
{
Console.WriteLine($"in stock: {book.Title}");
}
// Prototype and Memento (C# 9): record and with
Book discounted = dune with { Price = studentPrice(dune.Price) };
Console.WriteLine($"{dune.Price} -> {discounted.Price}");
// Decorator (C# 12): a wrapping class with a primary constructor
IPriceService prices = new LoggingPriceService(new PriceService());
Console.WriteLine(prices.PriceOf(dune));
while (outbox.TryDequeue(out Action? job))
{
job();
}
public sealed record Book(string Title, decimal Price, int Stock);
public static class Catalog
{
public static IEnumerable<Book> InStock(IEnumerable<Book> books)
{
foreach (Book book in books)
{
if (book.Stock > 0)
{
yield return book;
}
}
}
}
public interface IPriceService
{
decimal PriceOf(Book book);
}
public sealed class PriceService : IPriceService
{
public decimal PriceOf(Book book) => book.Price;
}
public sealed class LoggingPriceService(IPriceService inner) : IPriceService
{
public decimal PriceOf(Book book)
{
decimal price = inner.PriceOf(book);
Console.WriteLine($"log: price of {book.Title} is {price}");
return price;
}
}
in stock: Dune
in stock: Persuasion
12.99 -> 11.04
log: price of Dune is 12.99
12.99
email: order 1042 shipped
There is no IDiscountStrategy, ICommand, IIterator or Clone on any line. The patterns didn't disappear; their roles are still there (Strategy's swappable algorithm is studentPrice, Command's deferred request is the lambda in the queue), but the ceremony that carried them moved into the language. A summary of which release slimmed down which pattern:
| Pattern | Structure in the GoF | Lighter form in C# | Release |
|---|---|---|---|
| Observer | Subject and Observer interfaces | event | C# 1.0 |
| Iterator | An Iterator class and CreateIterator | yield return | C# 2.0 |
| Strategy, Command, Template Method | An interface and a family of classes | Func, Action and lambdas | C# 3.0 |
| Interpreter | A tree of expression classes | Expression trees | C# 3.0 |
| Visitor | The Accept and Visit pair | Type patterns and the switch expression | C# 7.0 and 8.0 |
| Prototype, Memento | Clone and a Memento class | record and with | C# 9.0 |
| Decorator | A component wrapped in the constructor | Primary constructors | C# 12 |
| Visitor, State, Result | A hierarchy not known to be closed | closed and union | C# 15 |
Behind every row is a post in this series. The releases come from the C# version history on Microsoft Learn; the "C# Versions: A Short History from 1.0 to 10" post in the C# Versions series tells the same story from the language side.
Up to the last row of the table, every slimming-down took something away along with the ceremony. Writing a switch instead of a Visitor also dropped the guarantee that "the compiler tells me when a new type is added"; the _ warning in Guess First was the proof. C# 15's two new tools close this gap.
C# 15's Two Tools: union and closed
union: Gathering Types Without a Common Ancestor into a Closed Set
A union declaration says that a value is exactly one of several types. These types are called case types, and they don't need a common base class or interface. Let's look at what the compiler generates with reflection:
using System.Reflection;
using System.Runtime.CompilerServices;
PlaceOrderResult result = new CartEmpty();
Console.WriteLine(result.Value);
Type type = typeof(PlaceOrderResult);
Console.WriteLine($"struct: {type.IsValueType}, [Union]: {type.IsDefined(typeof(UnionAttribute))}, IUnion: {type.IsAssignableTo(typeof(IUnion))}");
foreach (ConstructorInfo ctor in type.GetConstructors())
{
Console.WriteLine($"ctor({ctor.GetParameters()[0].ParameterType.Name})");
}
foreach (PropertyInfo property in type.GetProperties())
{
Console.WriteLine($"{property.PropertyType.Name} {property.Name}");
}
public sealed record Receipt(decimal Charged, int Items);
public sealed record CartEmpty;
public sealed record OutOfStock(string Isbn);
public union PlaceOrderResult(Receipt, CartEmpty, OutOfStock);
CartEmpty { }
struct: True, [Union]: True, IUnion: True
ctor(Receipt)
ctor(CartEmpty)
ctor(OutOfStock)
Object Value
The structure described in the union reference on Microsoft Learn shows up in the output: the compiler turns the union declaration into a struct, marks it with the [Union] attribute and the IUnion interface from System.Runtime.CompilerServices (both ship in the .NET 11 runtime), and generates a single-parameter constructor for each case type plus a Value property that holds the contents as object?. Those constructors give an implicit conversion from each case type to the union; that's why the assignment on the first line compiles.
A type pattern on a union (CartEmpty, Receipt r) tests the value inside, not the union itself; you don't need to write result.Value is CartEmpty. A switch that covers every case type counts as exhaustive. Being a struct has two costs: value-type cases such as int are boxed, and the Value of a default union is null. If needed, you can write your own union type with the [Union] attribute, as a class if you like.
closed: Locking a Hierarchy to Its Assembly
closed, on the other hand, closes an existing class hierarchy. The rule is short: classes that derive directly from a closed class must be declared in the same assembly as the base. We tried it with two projects:
// Bookstore.Orders project
namespace Bookstore.Orders;
public closed record OrderState;
public sealed record PendingPayment : OrderState;
public record Paid(decimal Amount) : OrderState;
// Bookstore.Plugins project (references Bookstore.Orders)
using Bookstore.Orders;
Console.WriteLine(new PaidByGiftCard(10m, "GC-1"));
public sealed record Refunded : OrderState;
public sealed record PaidByGiftCard(decimal Amount, string Code) : Paid(Amount);
Program.cs(5,22): error CS9382: 'Refunded': cannot use a closed type 'OrderState' from another assembly as a base type.
The only error is for Refunded: another assembly can't derive from the closed OrderState. PaidByGiftCard compiled, because closedness isn't transitive; if you want to protect Paid too, make it sealed or closed. Exhaustiveness doesn't break: the Paid arm also catches PaidByGiftCard.
The other rules: a closed class is implicitly abstract. It can't be combined with sealed, static or an explicit abstract (when we tried sealed closed, the compiler reported CS9381). It applies only to classes, record classes included. Because closed is a contextual keyword, old code that uses it as a variable name doesn't break.
Choosing between the two comes down to one question. If the types already share a meaning and common members (every order state is an OrderState), a closed hierarchy; if the types are independent and only come together at a specific point as "one of these" (the possible outcomes of a method, the commands that land in a queue), a union.
Rewriting Visitor
Let's rebuild the document model from the Visitor post with closed. The elements have no Accept, there's no IDocumentVisitor interface; each of the three operations is a single switch expression:
namespace Bookstore.Documents;
// No Accept, no visitor interface; the hierarchy is closed in this assembly
public closed record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
namespace Bookstore.Documents;
// Each operation used to be a visitor class; now it is a single switch expression
public static class Html
{
public static string Render(DocumentElement element) => element switch
{
Paragraph p => $"<p>{p.Text}</p>",
Table t => $"<table>{t.Header.Length} columns, {t.Rows.Length} rows</table>",
Image i => $"<img src=\"{i.Path}\" width=\"{i.WidthPx}\">",
};
}
public static class PlainText
{
public static string Render(DocumentElement element) => element switch
{
Paragraph p => p.Text,
Table t => string.Join(Environment.NewLine, new[] { t.Header }.Concat(t.Rows).Select(r => string.Join(" | ", r))),
Image i => $"[image: {i.Path}]",
};
}
public static class WordCount
{
public static int Count(DocumentElement element) => element switch
{
Paragraph p => Words(p.Text),
Table t => t.Header.Concat(t.Rows.SelectMany(r => r)).Sum(Words),
Image => 0,
};
private static int Words(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
using Bookstore.Documents;
DocumentElement[] invoice =
[
new Paragraph("Order #1042 for Ada Lovelace"),
new Table(["Title", "Qty"], [["Dune", "2"], ["Emma", "1"]]),
new Image("logo.png", 120),
];
foreach (DocumentElement element in invoice)
{
Console.WriteLine(Html.Render(element));
}
Console.WriteLine();
Console.WriteLine(string.Join(Environment.NewLine, invoice.Select(PlainText.Render)));
Console.WriteLine();
Console.WriteLine($"Words: {invoice.Sum(WordCount.Count)}");
<p>Order #1042 for Ada Lovelace</p>
<table>2 columns, 2 rows</table>
<img src="logo.png" width="120">
Order #1042 for Ada Lovelace
Title | Qty
Dune | 2
Emma | 1
[image: logo.png]
Words: 11
The output matches the output of the three visitors in the Visitor post; the word count is 11 again. Now let's repeat the experiment from the Visitor post and add the new element to Documents.cs without touching the operations:
// Added later; no switch was touched
public sealed record Quote(string Text, string Author) : DocumentElement;
Operations.cs(16,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Documents.Quote' is not covered.
Operations.cs(26,65): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Documents.Quote' is not covered.
Operations.cs(6,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Documents.Quote' is not covered.
In the Visitor design, the same addition produced three CS0535 errors in three visitor classes; here there are three CS8509 warnings in three switch expressions, and each one names the missing type. When we added <WarningsAsErrors>CS8509</WarningsAsErrors> to the project, the same lines came back as error CS8509 and the build stopped; Visitor's guarantee was back one to one.
So is Visitor dead? No, its territory got smaller. closed plus switch gives Visitor's balance in the expression problem (new operations cheap, new types expensive) with far less code, but only when the hierarchy lives in your assembly. For structures like ExpressionVisitor and Roslyn's CSharpSyntaxWalker, with hundreds of node types written by someone else, Visitor is still the right tool.
Rewriting State
In the State post, the state objects were shared and carried no data: the paid amount and the hold reason arrived as method parameters and only ended up in a log line. To keep them, you would have had to add nullable fields such as PaidAmount, HoldReason and TrackingNo to Order, and reading the tracking number of an order still awaiting payment would have been perfectly legal to the compiler. In a closed hierarchy each state carries its own data:
namespace Bookstore.Orders;
// Each state carries only its own data: only Shipped has a tracking number
public closed record OrderState;
public sealed record PendingPayment : OrderState;
public sealed record Paid(decimal Amount) : OrderState;
public sealed record OnHold(decimal Amount, string Reason) : OrderState;
public sealed record Shipped(string TrackingNo) : OrderState;
public sealed record Delivered : OrderState;
public sealed record Cancelled(string Reason) : OrderState;
// The commands share no base class; the union gathers them into a closed set
public sealed record Pay(decimal Amount);
public sealed record Hold(string Reason);
public sealed record Release;
public sealed record Ship(string TrackingNo);
public sealed record Deliver;
public sealed record Cancel(string Reason);
public union OrderCommand(Pay, Hold, Release, Ship, Deliver, Cancel);
public static class OrderWorkflow
{
// Transition table: every transition not listed is rejected
public static OrderState Apply(OrderState state, OrderCommand command) => (state, command) switch
{
(PendingPayment, Pay pay) => new Paid(pay.Amount),
(Paid paid, Hold hold) => new OnHold(paid.Amount, hold.Reason),
(OnHold held, Release) => new Paid(held.Amount),
(Paid, Ship ship) => new Shipped(ship.TrackingNo),
(Shipped, Deliver) => new Delivered(),
(PendingPayment or Paid or OnHold, Cancel cancel) => new Cancelled(cancel.Reason),
_ => throw new InvalidOperationException(
$"Cannot {command.Value?.GetType().Name} an order that is {state.GetType().Name}"),
};
// Questions every state must answer: no discard
public static decimal Refundable(OrderState state) => state switch
{
Paid paid => paid.Amount,
OnHold held => held.Amount,
PendingPayment or Shipped or Delivered or Cancelled => 0m,
};
public static string Describe(OrderState state) => state switch
{
PendingPayment => "waiting for payment",
Paid paid => $"paid {paid.Amount:0.00}",
OnHold held => $"on hold ({held.Reason})",
Shipped shipped => $"shipped, tracking {shipped.TrackingNo}",
Delivered => "delivered",
Cancelled cancelled => $"cancelled ({cancelled.Reason})",
};
}
using Bookstore.Orders;
OrderState order = new PendingPayment();
OrderCommand[] script =
[
new Pay(73m), new Hold("Fraud review"), new Ship("TR-5521"), new Release(), new Ship("TR-5521"), new Deliver(),
];
foreach (OrderCommand command in script)
{
try
{
order = OrderWorkflow.Apply(order, command);
Console.WriteLine($"{command.Value,-30} {OrderWorkflow.Describe(order)}, refundable {OrderWorkflow.Refundable(order):0.00}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{command.Value,-30} {ex.Message}");
}
}
Pay { Amount = 73 } paid 73.00, refundable 73.00
Hold { Reason = Fraud review } on hold (Fraud review), refundable 73.00
Ship { TrackingNo = TR-5521 } Cannot Ship an order that is OnHold
Release { } paid 73.00, refundable 73.00
Ship { TrackingNo = TR-5521 } shipped, tracking TR-5521, refundable 0.00
Deliver { } delivered, refundable 0.00
The tracking number exists only on Shipped, the hold reason only on OnHold; reading TrackingNo from a PendingPayment no longer compiles. The OrderCommand union gathers command records that share no base class, and type patterns in Apply such as Pay pay look at the value inside the union.
The two kinds of switch were written differently on purpose. Apply is a transition table: a transition not in the list is rejected, and the final _ arm is the State post's "the default answer is not allowed" rule itself. Refundable and Describe are questions every state must answer; they have no _. We replayed the story from the opening of the State post: when we added the OnHold record and built without touching anything else, the compiler said:
Orders.cs(35,65): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Orders.OnHold' is not covered.
Orders.cs(41,62): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Orders.OnHold' is not covered.
The refund amount and the on-screen description reported at compile time that they didn't know the new state. Apply stayed silent, because its _ arm already treats the new state as "not allowed", which is the right behavior. In the State post, adding OnHold to the enum made two methods silently do the wrong thing.
This doesn't replace the State pattern everywhere. With state classes a new state is one class; with closed and switch it is an arm in every switch, but the compiler points them out. If there's complex behavior per state, state classes read better; if the states are mostly data and transitions, a closed hierarchy does.
Rewriting Result
The Result<T> in the previous post represented failure with a single Error type carrying a Code and a Message. The caller learned which failures could come back by reading the code, not the signature. With a union, every possible outcome appears in the signature:
Checkout checkout = new(stock: new() { ["9780441013593"] = 4, ["9780141439518"] = 0 });
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", ["9780441013593"]), cardLimit: 50m)));
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", []), cardLimit: 50m)));
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", ["9780141439518"]), cardLimit: 50m)));
Console.WriteLine(Http.ToResponse(checkout.Place(new Cart("ada", ["9780441013593", "9780441013593"]), cardLimit: 20m)));
public sealed record Cart(string CustomerId, string[] Isbns);
// Every possible outcome with its own type and its own data
public sealed record Receipt(decimal Charged, int Items);
public sealed record CartEmpty;
public sealed record OutOfStock(string Isbn);
public sealed record PaymentDeclined(decimal Total, decimal Limit);
public union PlaceOrderResult(Receipt, CartEmpty, OutOfStock, PaymentDeclined);
public sealed class Checkout(Dictionary<string, int> stock)
{
private const decimal UnitPrice = 12.99m;
public PlaceOrderResult Place(Cart cart, decimal cardLimit)
{
if (cart.Isbns.Length == 0)
{
return new CartEmpty();
}
string? missing = cart.Isbns.FirstOrDefault(isbn => stock.GetValueOrDefault(isbn) <= 0);
if (missing is not null)
{
return new OutOfStock(missing);
}
decimal total = cart.Isbns.Length * UnitPrice;
return total <= cardLimit ? new Receipt(total, cart.Isbns.Length) : new PaymentDeclined(total, cardLimit);
}
}
public static class Http
{
// The caller must handle every outcome in the signature
public static string ToResponse(PlaceOrderResult result) => result switch
{
Receipt r => $"201 Created charged {r.Charged:0.00} for {r.Items} item(s)",
CartEmpty => "400 Bad Request the cart has no items",
OutOfStock o => $"409 Conflict {o.Isbn} is out of stock",
PaymentDeclined d => $"402 Payment Required {d.Total:0.00} exceeds the limit of {d.Limit:0.00}",
};
}
201 Created charged 12.99 for 1 item(s)
400 Bad Request the cart has no items
409 Conflict 9780141439518 is out of stock
402 Payment Required 25.98 exceeds the limit of 20.00
The return type of Place states the four possible outcomes, and each carries its own data: the ISBN (International Standard Book Number) of the out-of-stock book, the declined total, the card limit. return new CartEmpty(); compiles through the implicit conversion, and the conditional operator can convert two different records to the same union too. ToResponse has no _; if a CouponExpired is added tomorrow, the method that produces the HTTP (Hypertext Transfer Protocol) response will warn at compile time.
You don't have to give up the Then chain from the previous post either. A union declaration can have a body; you can't declare instance fields or auto-properties in it, but you can write methods. One of the case types can also be a type parameter:
Result<Cart> ok = new Cart("ada", ["9780441013593"]);
Result<Cart> empty = new Cart("ada", []);
Console.WriteLine(Describe(ok.Then(Validate).Then(Charge)));
Console.WriteLine(Describe(empty.Then(Validate).Then(Charge)));
static Result<Cart> Validate(Cart cart) =>
cart.Isbns.Length == 0 ? new Error("cart.empty", "The cart has no items.") : cart;
static Result<decimal> Charge(Cart cart) => cart.Isbns.Length * 12.99m;
static string Describe(Result<decimal> result) => result switch
{
decimal total => $"OK charged {total:0.00}",
Error error => $"FAIL {error.Code}: {error.Message}",
};
public sealed record Cart(string CustomerId, string[] Isbns);
public sealed record Error(string Code, string Message);
public union Result<T>(T, Error) where T : notnull
{
// On success run the next step, otherwise carry the error as is
public Result<TNext> Then<TNext>(Func<T, Result<TNext>> next) where TNext : notnull => this switch
{
T value => next(value),
Error error => error,
};
}
OK charged 12.99
FAIL cart.empty: The cart has no items.
The IsSuccess property, the Value! and Error! bangs and the private constructor are gone; Then is a two-arm switch. Which one to pick? If failure kinds are treated differently at a boundary (a different HTTP status, a different message), a union like PlaceOrderResult that makes each outcome its own case; if you only need to chain steps and carry the failure along, a generic Result<T>. A dropped database connection is still an exception.
Common Pitfalls
Forgetting the default union. A union is a struct, and the Value of a union that was never assigned is null:
PlaceOrderResult[] results = new PlaceOrderResult[2];
results[0] = new CartEmpty();
// results[1] was never assigned: default(PlaceOrderResult)
foreach (PlaceOrderResult result in results)
{
Console.WriteLine(ToResponse(result));
}
static string ToResponse(PlaceOrderResult result) => result switch
{
Receipt r => $"201 Created charged {r.Charged:0.00}",
CartEmpty => "400 Bad Request the cart has no items",
OutOfStock o => $"409 Conflict {o.Isbn} is out of stock",
};
public sealed record Receipt(decimal Charged, int Items);
public sealed record CartEmpty;
public sealed record OutOfStock(string Isbn);
public union PlaceOrderResult(Receipt, CartEmpty, OutOfStock);
400 Bad Request the cart has no items
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was PlaceOrderResult.
The compiler didn't warn, because when every case type is non-nullable, nullable analysis treats the union's contents as "not null"; array elements and fields that were never assigned come in through this door. The fix is a null arm: when we added null => "500 Internal Server Error the result was never set", the second line printed that text. Write null, not _, so the new-case warning survives.
Adding a _ arm to an exhaustive switch out of habit. A _ arm catches everything, including the case you add tomorrow. It's the shortest way to give back the gain from Guess First. Use _ only deliberately, in the sense of "everything not listed", as in Apply.
Mistaking CS8509 for an error. As long as it stays a warning, the build server stays green and the missing arm shows up in production as a SwitchExpressionException. In projects that use closed and union, the <WarningsAsErrors>CS8509</WarningsAsErrors> line is what turns the guarantee in this post into a real one.
Assuming closed spreads to lower levels. As the Paid example showed, closedness applies only to direct subtypes. If you don't want others to extend an intermediate class, make it sealed or closed as well.
Letting the cases of a generic union collide. Using Result<T>(T, Error) as Result<Error> turned both cases into the same type, and assigning new Error(...) failed with CS0457 ("ambiguous user defined conversions").
When Not to Use It
closed, when the hierarchy really is open. Closing a structure where plugins or other teams must add subtypes (a payment provider, a report format) destroys the extension point; there, virtual methods are still the right answer.
union, when the shared behavior already lives in an interface. If the caller never asks which case it got, an interface and virtual dispatch mean less code; a union pays for itself when each case must be handled separately.
For two cases. Instead of building a union for "found or not found", a nullable return type or the Try pattern is usually enough; the rule from the Clean Code series post "Error Handling in Clean Code: Exceptions, the Try Pattern and the Result Pattern" applies here too.
For the pattern's own sake. The C# 15 edition of the patternitis from the Anti-Patterns post is turning every enum into a closed hierarchy and every return type into a union. For a list of states with no behavior, an enum is still enough.
A Small Experiment
★☆☆ Delete the general Image i arm from Html.Render in Guess First and keep only the Image { WidthPx: > 600 } i arm. Does the compiler still consider the switch exhaustive? Which value does the warning show as an example?
★★☆ Add a Refunded(decimal Amount) state and a Refund command to the order model. Which switch expressions warned, and which one stayed silent? Then rewrite Apply so that the outer switch is exhaustive over the state (each state handles its own allowed commands inside), and compare where a new state could slip through.
★★★ Write your own class-based Result<T> type with the [Union] attribute: one constructor per case and a Value property are enough. Add a ThenAsync next to Then. What is the difference between the default behavior of a generated union and a class-based union?
This post completes the Design Patterns series: the GoF's 23 patterns, anti-patterns, patterns beyond the GoF, and how the language changes those answers. The natural next step is the system level: reread the "Event-Sourced Aggregates with the Decider Pattern: decide, evolve, and Given-When-Then Tests" post of the CQRS (Command Query Responsibility Segregation) and Event Sourcing series with this in mind; commands and events are natural unions, and the aggregate's state is a natural fit for a closed hierarchy.