Switch expressions are a convenient way to handle multiple conditions in C#. They make code shorter, reduce unnecessary if-else statements, and work particularly well with pattern matching.
However, there is one problem developers often overlook. A switch expression can compile even when it does not handle every possible input value. The compiler may generate a warning, but if that warning is ignored, an unexpected value can cause an exception at runtime.
This becomes more important when applications have multiple business states, payment methods, order statuses, or response types. When a new state is introduced, existing switch expressions may continue to compile without properly handling the new scenario if they contain a general fallback case.
C# provides compiler diagnostics for incomplete switch expressions. With newer language features such as union types and closed class hierarchies in C# 15, developers can also define a known set of possible types and let the compiler check whether every case is handled.
In this article, we will explore how switch expressions work, why incomplete cases create problems, and how to write safer switch expressions using compiler-checked patterns.
Understanding Switch Expressions in C#
A switch expression evaluates an input value against multiple patterns and returns a result based on the first matching case.
Consider an application that converts an order status into a readable message.
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}
public static string GetOrderMessage(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => "Your order is waiting for processing.",
OrderStatus.Processing => "Your order is being prepared.",
OrderStatus.Shipped => "Your order is on its way.",
OrderStatus.Delivered => "Your order has been delivered.",
OrderStatus.Cancelled => "Your order was cancelled."
};
}
The switch expression maps each order status to a corresponding message.
Unlike a traditional switch statement, a switch expression returns a value. This makes it useful when assigning results, returning values from methods, and implementing business rules.
However, the compiler must also consider whether every possible input value has been covered.
Why Incomplete Switch Expressions Can Cause Runtime Problems
Consider what happens when a switch expression does not handle every possible value.
public static string GetOrderMessage(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => "Order pending",
OrderStatus.Processing => "Order processing",
OrderStatus.Shipped => "Order shipped"
};
}
The expression does not handle Delivered or Cancelled.
The compiler reports a non-exhaustive switch warning, CS8509, because some possible values are not covered.
If the application receives one of those values, the switch expression can throw a System.Runtime.CompilerServices.SwitchExpressionException at runtime.
This is especially important when an enum contains more values than the developer initially expects.
For example, an enum can receive an integer value that does not correspond to any declared member.
OrderStatus status = (OrderStatus)10;
C# allows this conversion. It does not automatically validate whether the integer represents a declared enum member.
Therefore, handling only the named enum members is not always enough for defensive application code.
Using Compiler Diagnostics to Identify Missing Cases
C# provides compiler diagnostics that help identify incomplete or incorrectly structured switch expressions.
Some useful diagnostics include:
Diagnostic | Meaning | Recommended action |
|---|
CS8509 | Switch expression does not handle all possible values | Add the missing patterns |
CS8524 | Some unnamed enum values are not handled | Add an appropriate fallback |
CS8846 | A switch expression may be incomplete because of a guarded pattern | Review the when conditions |
CS8510 | A pattern is unreachable | Remove or reorder the redundant pattern |
CS9336 | A pattern is redundant | Simplify the matching conditions |
These diagnostics help developers catch problems before the application reaches production.
For example, when a switch expression contains an unnecessary pattern after a general pattern, the compiler can identify that the later case will never execute.
This is useful because a switch expression should not only handle all possible values but should also avoid unreachable or redundant cases.
Handling Enum Values Safely
Enums are commonly used to represent fixed sets of application states. They are also a good example of why compiler-checked switch expressions need careful design.
Consider the order status example again.
A defensive implementation can include a discard pattern.
public static string GetOrderMessage(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => "Order pending",
OrderStatus.Processing => "Order processing",
OrderStatus.Shipped => "Order shipped",
OrderStatus.Delivered => "Order delivered",
OrderStatus.Cancelled => "Order cancelled",
_ => throw new ArgumentOutOfRangeException(
nameof(status),
status,
"Unknown order status.")
};
}
The discard pattern _ matches any value that has not been handled by the previous cases.
This prevents an unmatched enum value from reaching the runtime switch failure. Instead, the application throws an exception with a meaningful message.
However, there is an important trade-off.
When a discard pattern is present, adding another enum member will not necessarily produce a missing-case warning. The fallback already handles it.
For internal business logic where every new status should be reviewed, developers may prefer an exhaustive switch without a discard pattern. For external or untrusted values, a fallback can be more appropriate.
The choice depends on whether the application prioritizes detecting new cases during development or safely handling unknown values at runtime.
Using C# 15 Closed Hierarchies for Compiler-Checked Cases
Traditional inheritance allows developers to extend a base class from different assemblies. This makes it difficult for the compiler to know every possible derived type.
C# 15 introduces closed class hierarchies, allowing developers to restrict direct inheritance to the assembly containing the closed base class.
This gives the compiler a known set of direct descendants and makes exhaustive switch expressions possible without requiring a catch-all pattern.
Consider a payment processing application.
A payment can be made through cash, a card, or a bank transfer.
First, define a closed base class and its derived types.
public closed record class PaymentMethod;
public record class CashPayment : PaymentMethod;
public record class CardPayment(string LastFourDigits)
: PaymentMethod;
public record class BankTransfer(string ReferenceNumber)
: PaymentMethod;
The closed modifier ensures that direct derived classes must be declared in the same assembly as the base class.
Now create a method that describes the payment method.
public static string GetPaymentDescription(
PaymentMethod payment)
{
return payment switch
{
CashPayment => "Payment made using cash.",
CardPayment card =>
$"Card payment ending in {card.LastFourDigits}.",
BankTransfer transfer =>
$"Bank transfer reference: {transfer.ReferenceNumber}"
};
}
Notice that there is no discard pattern.
The compiler knows the direct descendants of PaymentMethod and can verify that every case is handled.
If another payment type is introduced:
public record class WalletPayment(string Provider)
: PaymentMethod;
Existing switch expressions that do not handle WalletPayment can produce a non-exhaustive switch warning.
This is particularly useful in business applications where introducing a new payment type should trigger a review of existing payment-handling logic.
Using Union Types With Exhaustive Switch Expressions
C# 15 also introduces native union types. They provide another way to define a fixed set of possible alternatives.
For example, an API operation might return a successful customer, a not-found result, or a validation error.
public sealed record Customer(int Id, string Name);
public sealed record CustomerNotFound(int CustomerId);
public sealed record ValidationError(string Message);
public union CustomerResult(
Customer,
CustomerNotFound,
ValidationError
);
Now a switch expression can handle each declared alternative.
public static string GetResultMessage(CustomerResult result)
{
return result switch
{
Customer customer =>
$"Customer found: {customer.Name}",
CustomerNotFound error =>
$"Customer {error.CustomerId} was not found.",
ValidationError error =>
$"Validation failed: {error.Message}"
};
}
Because the compiler knows the union's declared cases, it can verify whether the switch expression handles every alternative.
If another case is added to CustomerResult, existing switch expressions that do not handle it can generate a compiler warning.
This is useful when methods have a defined set of possible outcomes and every calling method should respond to them explicitly.
Making Compiler Warnings Part of the Build Process
Writing exhaustive switch expressions is useful, but teams should also ensure that important compiler warnings are not ignored.
One way to strengthen the build process is to treat warnings as errors.
Add the following configuration to the project file:
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
With this configuration, compiler warnings can fail the build.
This helps prevent incomplete switch expressions from reaching later stages of the development pipeline.
However, treating every warning as an error can require additional work when upgrading dependencies or frameworks. Teams should review existing warnings and adopt the setting consistently rather than suppressing diagnostics without understanding them.
For larger applications, CI pipelines can also enforce a successful build before allowing changes to be merged.
Advantages and Disadvantages of Compiler-Checked Switch Expressions
Advantages
Earlier problem detection: Missing cases can be identified during compilation rather than discovered through production failures.
Better maintainability: When a new state or type is introduced, existing switch expressions can identify where updates are required.
Clearer business logic: Each possible state has an explicit implementation.
Reduced fallback dependency: Closed hierarchies and unions can eliminate unnecessary catch-all patterns.
Better code reviews: Developers can easily review how each supported case is handled.
Disadvantages
Additional design considerations: Developers must decide whether to use enums, unions, or closed class hierarchies.
Refactoring effort: Introducing a new case may require updating several switch expressions.
Language requirements: Native unions and closed hierarchies require C# 15 support.
Limited exhaustiveness analysis: The compiler cannot prove every possible combination of arbitrary patterns and conditions.
External input validation: Compiler-checked cases do not automatically validate values received from APIs, databases, or user input.
Best Practices for Writing Safer Switch Expressions
Avoid Unnecessary Discard Patterns
Do not automatically add _ => to every switch expression.
For closed hierarchies and union types, omitting the fallback allows the compiler to identify missing cases when the declared alternatives change.
Use a fallback when the application needs to handle unknown values safely.
Keep Pattern Conditions Readable
Avoid combining too many conditions into a single switch arm.
Use property patterns and when guards where they improve readability, but keep complex business rules in separate methods when necessary.
Validate External Values
A compiler cannot guarantee that data received from an external API or database contains a valid business state.
Validate external input before converting it into internal domain types.
Prefer Explicit Business States
Use meaningful enum members, union cases, and derived types rather than relying on unexplained numeric values or generic object instances.
Review Switch Expressions When Adding New Cases
When introducing a new payment method, order status, or response type, review all related switch expressions.
Compiler diagnostics should be part of this process, not the only validation step.
Common Mistakes and Troubleshooting
Problem | Possible reason | Recommended solution |
|---|
CS8509 warning | One or more input cases are missing | Add the missing patterns |
CS8524 warning | An enum contains possible unnamed values | Add a fallback for unknown enum values |
CS8510 error | A previous pattern already handles the case | Reorder or remove the unreachable pattern |
CS8846 warning | A when guard leaves some values uncovered | Add patterns for the remaining possibilities |
New type is not detected | A general discard pattern handles every remaining value | Use a closed hierarchy or union without an unnecessary fallback |
Runtime switch exception | No pattern matched the actual input | Validate the input and provide appropriate fallback handling |
Summary
Switch expressions make C# code easier to read and maintain, but incomplete pattern matching can still introduce runtime problems.
Compiler diagnostics such as CS8509, CS8524, and CS8846 help developers identify missing cases and improve the reliability of their code.
C# 15 takes this further with native union types and closed class hierarchies. These features allow developers to define a known set of possible alternatives and write switch expressions that the compiler can check for exhaustiveness.
For production applications, the important practice is to make every supported business state explicit, validate external input, and use fallback patterns only when they serve a clear purpose.
By combining pattern matching, compiler diagnostics, and appropriate language features, developers can write switch expressions that are easier to maintain and less likely to fail when application requirements change.
C#switch expressionspattern matchingcompiler diagnosticsexhaustive switchenumsCS8509SwitchExpressionException
Join the conversation! Your thoughts help the community grow.