Switch expressions in depth: syntax and semantics versus switch statements, when clauses, natural and target typing, exhaustiveness checking, tuple-based state machines and readability trade-offs.
A switch expression returns a value: status switch { Paid => "Paid", _ => "Other" }. Each arm is a pattern, an optional when guard, and an expression. There's no fall-through or break, and the first matching arm wins. The result type comes from the arms or from the target. The compiler warns with CS8509 if it isn't exhaustive, and an unmatched value throws SwitchExpressionException. Switching on a tuple like (state, trigger) gives very readable state machines. When an arm needs several statements or side effects, a classic switch statement or polymorphism is clearer.
A switch statement chooses which statements to execute; a switch expression (C# 8) produces a value from the first matching arm. Switch expressions are concise, composable and checked for exhaustiveness — ideal for mapping inputs to outputs and for state machines. I'd cover arms, guards, discards, target typing, exhaustiveness and SwitchExpressionException, tuple switches, and when it's the wrong tool.
// Switch statement: control flow
switch (status)
{
case OrderStatus.Paid:
await ShipAsync(order, ct);
break;
case OrderStatus.Cancelled:
await RefundAsync(order, ct);
break;
default:
throw new InvalidOperationException();
}
// Switch expression: value
int statusCode = result switch
{
Ok => 200,
NotFound => 404,
ValidationFailed v => 422,
_ => 500
};
| Switch statement | Switch expression | |
|---|---|---|
| Produces a value | No | Yes |
| Bodies | Statements (multiple lines, break) |
Single expression per arm |
| Fall-through | goto case, stacked labels |
None |
| Exhaustiveness | Not required | Warned (CS8509); runtime SwitchExpressionException |
Patterns, when guards |
Yes | Yes |
| Can be used inline (arguments, returns, initialisers) | No | Yes |
decimal Fee(Payment p) => p switch
{
{ Method: PaymentMethod.Card, Amount: < 10m } => 0.30m, // property + relational pattern
{ Method: PaymentMethod.Card } card when card.IsInternational => card.Amount * 0.035m, // guard
{ Method: PaymentMethod.Card } card => card.Amount * 0.0175m,
{ Method: PaymentMethod.PayTo } => 0.10m,
null => throw new ArgumentNullException(nameof(p)),
_ => 0m // discard
};
pattern [when guard] => expression. First match wins, top to bottom.when adds arbitrary conditions (but the compiler can't use them for exhaustiveness)._ matches anything; place it last.throw expressions are allowed as arm results.The switch expression's type is the best common type of its arms or, if there isn't one, the target type (C# 9):
IShape s = kind switch { "circle" => new Circle(1), "square" => new Square(2), _ => throw new ArgumentException() };
int? limit = tier switch { Tier.Gold => 100, Tier.Silver => 50, _ => null }; // null arm OK because target is int?
Without a target type (var x = kind switch { ... Circle ... Square ... }), it fails to compile if there's no common type.
SwitchExpressionException_ even when all named values are listed, because enums can hold any integer.SwitchExpressionException (with the unmatched value in UnmatchedValue).ArgumentOutOfRangeException/UnreachableException) for impossible cases.public static OrderState Next(OrderState state, OrderTrigger trigger) => (state, trigger) switch
{
(OrderState.Draft, OrderTrigger.Submit) => OrderState.Pending,
(OrderState.Pending, OrderTrigger.Pay) => OrderState.Paid,
(OrderState.Pending, OrderTrigger.Cancel) => OrderState.Cancelled,
(OrderState.Paid, OrderTrigger.Ship) => OrderState.Shipped,
(OrderState.Paid, OrderTrigger.Cancel) => OrderState.Refunding,
(_, _) => throw new InvalidOperationException($"Cannot {trigger} when {state}")
};
The transition table is readable and every valid transition is in one place; invalid ones throw. For larger workflows with side effects, guards and persistence, a library (Stateless) or a dedicated state pattern may be better.
FrozenDictionary or configuration.=> SendAsync(...)) but awaiting inside arms isn't possible; a switch statement with await reads better.Order status transitions were spread across services with if (order.Status == Paid && action == "ship") checks; an invalid transition (shipping a cancelled order) slipped through. We centralised transitions in a tuple switch expression like the one above, with tests for every valid and invalid pair. Adding "Partially Shipped" later meant adding two arms and tests, and the discard arm made invalid transitions fail loudly.
Checks fluent use of switch expressions and design judgement.
Green flags: expression versus statement differences; guards; typing; exhaustiveness and SwitchExpressionException; tuple state machines; knows when not to use them.
Red flags: thinks there is fall-through; ignores exhaustiveness; uses switch expressions for side-effect-heavy code.
A switch expression compiles into a decision DAG similar to a switch statement, assigning the matched arm's value to a temporary. An unmatched path calls ThrowHelper to throw SwitchExpressionException (or InvalidOperationException on older targets). For tuple switches, the compiler evaluates the tuple elements once and tests each position — no tuple allocation (ValueTuple struct).
For dense integer/enum constants, the compiler emits an IL switch instruction (jump table). For strings, it generates length/character checks or hash-based switching. Type patterns become sequential type checks. Performance is typically equal to or better than equivalent if/else chains.
bool inputs: true and false arms suffice.null must be covered (or _).(_, _) covers everything.< 0, 0, > 0 is exhaustive for int).when clauses: ignored for coverage.Switch statements also support patterns, when, and since C# 7 can switch on any type. They require each section to end with break, return, throw, continue or goto case. C# 9 added or/and patterns in case labels (case 1 or 2:).
private static readonly FrozenDictionary<string, decimal> StateShipping = new Dictionary<string, decimal>
{
["NSW"] = 9.95m, ["VIC"] = 9.95m, ["WA"] = 19.95m
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
When mappings change often or come from business users, configuration or database tables beat code.
Use data-driven tests ([Theory] with [InlineData]/[MemberData]) covering every arm, boundary values for relational patterns, and the discard/throw path. For state machines, a full transition matrix test (all states × triggers) catches gaps.
_ and call it with an undefined value.namespace Advanced.SwitchExpressions;
public enum OrderState { Pending, Paid, Shipped, Delivered, Cancelled, Refunded }
public enum OrderTrigger { Pay, Ship, Deliver, Cancel, Refund }
public enum PaymentMethod { Card, PayTo, BankTransfer }
public static class Workflow
{
public static OrderState Next(OrderState state, OrderTrigger trigger) => (state, trigger) switch
{
(OrderState.Pending, OrderTrigger.Pay) => OrderState.Paid,
(OrderState.Pending, OrderTrigger.Cancel) => OrderState.Cancelled,
(OrderState.Paid, OrderTrigger.Ship) => OrderState.Shipped,
(OrderState.Paid, OrderTrigger.Refund) => OrderState.Refunded,
(OrderState.Shipped, OrderTrigger.Deliver) => OrderState.Delivered,
_ => throw new InvalidOperationException($"Cannot {trigger} an order that is {state}."),
};
public static decimal Fee(PaymentMethod method, decimal amount) => method switch
{
PaymentMethod.Card when amount > 1_000m => decimal.Round(amount * 0.015m, 2),
PaymentMethod.Card => decimal.Round(amount * 0.02m, 2),
PaymentMethod.PayTo => 0.25m,
PaymentMethod.BankTransfer => 0m,
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown payment method"),
};
public static string Band(int score) => score switch
{
< 0 or > 100 => throw new ArgumentOutOfRangeException(nameof(score)),
>= 85 => "High distinction",
>= 75 => "Distinction",
>= 65 => "Credit",
>= 50 => "Pass",
_ => "Fail",
};
}
public static class Program
{
public static void Main()
{
var state = OrderState.Pending;
state = Workflow.Next(state, OrderTrigger.Pay);
state = Workflow.Next(state, OrderTrigger.Ship);
Console.WriteLine(state); // Shipped
Console.WriteLine(Workflow.Fee(PaymentMethod.Card, 2_000m)); // 30.00
Console.WriteLine(Workflow.Band(78)); // Distinction
object labelled = state == OrderState.Shipped switch { true => 1, false => "no" }; // target-typed
Console.WriteLine(labelled);
try { Workflow.Next(OrderState.Delivered, OrderTrigger.Pay); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
}
}
What happens: missing _ for enums or open types.
Why it is wrong: SwitchExpressionException at run time.
Do instead: add a discard with a meaningful exception; treat CS8509 as an error.
What happens: arms call methods that send emails or update databases, with the result discarded. Why it is wrong: obscures control flow; switch expressions are for values. Do instead: use a switch statement or return actions to execute separately.
What happens: general arms before specific ones. Why it is wrong: unreachable arms (errors) or wrong matches with guards. Do instead: specific to general.
What happens: hundreds of constant arms mapping codes to values. Why it is wrong: hard to maintain; requires deployments for data changes. Do instead: dictionaries, configuration or database tables.
What happens: var shape = kind switch { ... Circle ..., ... Square ... };.
Why it is wrong: no common type — compile error.
Do instead: declare the target type (IShape shape = ...).
What happens: multi-line LINQ chains or nested ternaries inside arms. Why it is wrong: unreadable and hard to debug. Do instead: extract methods.
What happens: the same switch on subtypes in many places. Why it is wrong: every new type requires multiple edits. Do instead: polymorphism or a single central mapping.
when for core logicWhat happens: most conditions in guards. Why it is wrong: loses exhaustiveness and pattern clarity. Do instead: use property/relational patterns where possible.
Switch expressions turn branching logic into reviewable tables, and with exhaustiveness checking they catch missing cases at build time. I use tuple switches for state machines and rule tables, keep arms to single expressions, and treat CS8509 as an error. I also recognise the limit: once arms need multi-step logic or the type set is open, a switch statement or polymorphism reads better.
C# patterns now cover most data checks. There are type, constant and null patterns, relational ones like >= 100, logical and, or and not combinators, and property patterns, including extended ones like { Customer.Address.State: "NSW" }. There are also positional patterns that use Deconstruct, var and discard patterns, and list patterns like [first, .., last]. The compiler turns a switch into an efficient decision tree, warns when it isn't exhaustive, and errors on unreachable cases. I use patterns for closed sets of data shapes and polymorphism when new types keep being added.
An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.
How to tell it (STAR)
**S:** We were fixing impossible order states by hand every week. **T:** Make invalid states impossible. **A:** I replaced independent flags with a single state and a switch-expression state machine that rejects invalid transitions, migrated the data, and tested every transition. **R:** The weekly data fixes stopped and support tickets for that issue dropped to [number].
Last updated 30 Oct 2026.