Introduction
Polymorphism is one of the core ideas developers use when designing object-oriented C# applications. Interfaces and abstract base classes make it possible to work with different implementations through a common contract.
That approach is powerful, but it also has a limitation: the compiler generally cannot assume that the list of implementations is complete.
A new class can implement an interface later. Another class can inherit from an abstract base class. Existing switch expressions therefore often need a fallback case because the compiler cannot prove that every possible type has been handled.
C# 15 introduces closed hierarchies through union types, giving developers another way to model a deliberately fixed set of alternatives. The important difference is not simply syntax. It changes how the compiler understands the relationship between the possible cases.
This article compares traditional polymorphism with C# 15 closed hierarchies and explains where exhaustive pattern matching can make application code easier to maintain.
What Is a Closed Hierarchy?
A closed hierarchy is a type model where the possible alternatives are known and intentionally limited.
Consider a payment result:
public abstract class PaymentResult
{
}
public sealed class PaymentSucceeded : PaymentResult
{
public decimal Amount { get; init; }
}
public sealed class PaymentFailed : PaymentResult
{
public string Reason { get; init; } = string.Empty;
}
A method can process the hierarchy with pattern matching:
static string FormatResult(PaymentResult result)
{
return result switch
{
PaymentSucceeded success =>
$"Payment completed: {success.Amount}",
PaymentFailed failure =>
$"Payment failed: {failure.Reason}",
_ => throw new ArgumentOutOfRangeException()
};
}
The _ arm is still required because the compiler does not treat the abstract base class as a truly closed set of implementations.
Someone could later add:
public sealed class PaymentPending : PaymentResult
{
}
The compiler cannot assume that PaymentPending will never exist.
C# 15 union types address this specific problem by allowing the set of cases to be declared explicitly.
Defining a C# 15 Union
A union can describe the complete set of payment outcomes:
public record class PaymentSucceeded(decimal Amount);
public record class PaymentFailed(string Reason);
public union PaymentResult(
PaymentSucceeded,
PaymentFailed);
The union itself represents one of the declared cases.
A method can then use an exhaustive switch:
static string FormatResult(PaymentResult result)
{
return result switch
{
PaymentSucceeded success =>
$"Payment completed: {success.Amount}",
PaymentFailed failure =>
$"Payment failed: {failure.Reason}"
};
}
There is no fallback arm.
That is significant because the compiler knows which types can be stored in PaymentResult.
If another case is added to the union, code that performs exhaustive matching can be identified by the compiler as requiring attention.
Traditional Polymorphism vs Closed Unions
The two approaches solve related problems but have different design goals.
| Feature | Traditional Polymorphism | C# 15 Union |
|---|---|---|
| Open for new implementations | Yes | No, by design |
| Fixed set of cases | Not guaranteed | Yes |
| Exhaustive pattern matching | Usually requires fallback | Supported |
| Interface compatibility | Yes | No |
| Third-party implementations | Possible | Not the intended model |
| Compiler knows all cases | Generally no | Yes |
| Good for plugin architectures | Yes | No |
| Good for finite state models | Possible | Strong fit |
The important question is therefore not "Which one is faster?"
It is:
Is the type hierarchy supposed to be open or closed?
That design decision should come before the choice of syntax.
Why Exhaustive Matching Matters
Consider an order-processing application.
An order can be:
Pending
Paid
Cancelled
Suppose the application has several methods that process those states.
With an open inheritance hierarchy, a developer might write:
static string GetMessage(OrderState state)
{
return state switch
{
Pending => "Waiting for payment",
Paid => "Payment received",
Cancelled => "Order cancelled",
_ => "Unknown state"
};
}
The fallback prevents the compiler from telling you when the domain model changes.
Now imagine a new state:
Refunded
The application may compile successfully even though several parts of the application have not been updated.
With a closed union:
public record class Pending;
public record class Paid;
public record class Cancelled;
public union OrderState(
Pending,
Paid,
Cancelled);
The compiler knows the complete set of alternatives.
That turns an architectural rule into something the compiler can help enforce.
Closed Hierarchies and Domain Modeling
Closed types are particularly useful when a domain concept has a finite number of states.
For example:
public record class Pending;
public record class Approved;
public record class Rejected;
public union ApprovalResult(
Pending,
Approved,
Rejected);
The application can then express the domain directly:
static string Describe(ApprovalResult result)
{
return result switch
{
Pending => "Approval is still pending.",
Approved => "Request was approved.",
Rejected => "Request was rejected."
};
}
The code communicates something important to another developer:
These are the supported states.
That is stronger than relying on documentation alone.
Comparing Maintenance Behavior
The difference becomes clearer when the model evolves.
Suppose the original model contains three cases:
Case A
Case B
Case C
Later, a fourth case is added:
Case D
With traditional polymorphism:
Add new class
|
v
Existing switches may still compile
|
v
Potential missing behavior
With a closed union:
Add new union case
|
v
Compiler checks exhaustive matches
|
v
Update affected code
This does not eliminate the need for testing, but it moves one class of errors from runtime behavior into compile-time feedback.
Does Exhaustive Matching Improve Performance?
Not automatically.
It is tempting to assume that because the compiler knows all possible cases, union-based pattern matching must be faster than traditional polymorphism.
That conclusion requires measurement.
The generated implementation, runtime version, case types, and access pattern all influence the actual result.
A simple benchmark can compare representative methods:
[Benchmark]
public string ProcessTraditional()
{
return ProcessTraditionalResult(_traditionalResult);
}
[Benchmark]
public string ProcessUnion()
{
return ProcessUnionResult(_unionResult);
}
The benchmark should use the same underlying work for both implementations.
For example, if one method performs additional allocations or string operations, the benchmark is no longer isolating the dispatch mechanism.
Benchmarking Pattern Matching
BenchmarkDotNet can be used to compare the implementations:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class DispatchBenchmark
{
private readonly PaymentResult _unionResult =
new PaymentSucceeded(100);
private readonly PaymentResultBase _traditionalResult =
new PaymentSucceededBase(100);
[Benchmark]
public string Union()
{
return ProcessUnion(_unionResult);
}
[Benchmark]
public string Traditional()
{
return ProcessTraditional(_traditionalResult);
}
private static string ProcessUnion(PaymentResult result)
{
return result switch
{
PaymentSucceeded success =>
success.Amount.ToString(),
PaymentFailed failure =>
failure.Reason
};
}
private static string ProcessTraditional(
PaymentResultBase result)
{
return result switch
{
PaymentSucceededBase success =>
success.Amount.ToString(),
PaymentFailedBase failure =>
failure.Reason,
_ => throw new ArgumentOutOfRangeException()
};
}
}
The benchmark should be run in Release mode:
dotnet run -c Release
Do not publish fixed performance numbers without actually running the benchmark in a controlled environment.
A benchmark result depends on the machine, runtime build, compiler, workload, and implementation details.
When Traditional Polymorphism Is Still Better
Closed unions are not intended to replace interfaces and abstract classes.
Suppose an application supports payment providers:
public interface IPaymentProvider
{
Task ProcessAsync(Payment payment);
}
Different teams or external packages may implement:
public class StripePaymentProvider : IPaymentProvider
{
public Task ProcessAsync(Payment payment)
{
// Implementation
return Task.CompletedTask;
}
}
public class BankPaymentProvider : IPaymentProvider
{
public Task ProcessAsync(Payment payment)
{
// Implementation
return Task.CompletedTask;
}
}
This is an open system.
The application benefits from allowing new implementations without changing a central union declaration.
A union would be a poor fit here because the entire purpose of the abstraction is extensibility.
When Closed Unions Are a Better Fit
A union is more appropriate when the application owns the complete set of cases.
Good examples include:
Parser results
Authentication outcomes
Domain states
Command variants
Protocol messages
Validation results
Workflow states
Finite business outcomes
For example:
public record class Valid(string Value);
public record class Invalid(string Error);
public union ValidationResult(
Valid,
Invalid);
Every consumer can handle both outcomes explicitly.
Common Mistakes
Treating a Closed Type as an Extensible Abstraction
If other teams need to add implementations, a union can create unnecessary coupling.
Use an interface or abstract base type when extensibility is a requirement.
Adding a Catch-All Without Thinking
A fallback arm such as:
_ => throw new InvalidOperationException()
can be useful when dealing with an open hierarchy.
For a closed union, however, exhaustive matching is one of the main benefits. Avoid hiding missing cases unnecessarily.
Assuming Compiler Exhaustiveness Means Business Correctness
The compiler can verify that every type is handled.
It cannot verify that your business logic is correct.
This is still wrong:
return result switch
{
PaymentSucceeded => "Payment failed",
PaymentFailed => "Payment completed"
};
All cases are handled, but the behavior is incorrect.
Unit and integration tests are still required.
Using Unions Everywhere
Not every model needs a closed hierarchy.
Introducing a union where a simple class or interface is enough can make the design harder to understand.
Best Practices
Decide Open vs Closed First
Ask whether new implementations should be allowed.
If yes, traditional polymorphism is usually the better model.
If no, a closed union can make that restriction explicit.
Keep Cases Focused
Each union case should represent a meaningful alternative in the domain.
Avoid creating large unions containing unrelated concepts simply because the language permits it.
Use Exhaustive Matching
Take advantage of compiler-checked completeness when the domain is intentionally closed.
Benchmark Only When Performance Matters
Do not introduce a more complex type model because of assumed performance benefits.
Measure representative workloads first.
Combine With Tests
Compile-time exhaustiveness protects against missing cases. Tests protect against incorrect behavior.
You need both.
Advantages
Makes a closed set of alternatives explicit.
Enables exhaustive pattern matching.
Gives the compiler more information about possible cases.
Helps identify affected code when the domain model changes.
Works well for finite state and result models.
Can make domain logic easier to understand.
Disadvantages
Not suitable for open extensibility scenarios.
Preview language features require additional caution while being evaluated.
Existing polymorphic architectures may require redesign to use unions.
Exhaustive matching does not guarantee correct business behavior.
Performance benefits should not be assumed without measurement.
Conclusion
C# 15 closed hierarchies provide an interesting alternative to traditional polymorphism when a domain has a deliberately fixed set of possibilities.
The biggest benefit is not necessarily performance. It is correctness and maintainability.
With an interface or abstract base class, the compiler generally cannot know every future implementation. With a closed union, the set of cases is part of the type definition, allowing exhaustive pattern matching to become a compile-time check.
That makes unions particularly useful for finite domain concepts such as states, results, commands, and protocol variants.
Traditional polymorphism remains the better choice when extensibility is important.
The practical rule is simple: use open abstractions for open domains and closed unions for closed domains. Then benchmark the implementation only when runtime performance is an actual requirement rather than an assumption.

Jasen FiciPosted Sep 4, 2026, 12:43 PM
Great writeup — we included it in DotNetNews here: https://dotnetnews.co/archive/the-net-news-daily-issue-534/