📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do switch expressions differ from switch statements in C#? Explain arms, guards, discards, target typing of the result, exhaustiveness and SwitchExpressionException, tuple switches for state machines, and when a switch expression is the wrong tool.

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.

Mid-Level Coding Intermediate csharp pattern-matching readability state-machines switch-expressions

🎯 Key points

  • A switch expression (C# 8) produces a value: `input switch { pattern => result, ... }`; arms are expressions, not statements
  • No fall-through and no break; first matching arm wins, evaluated top to bottom
  • `when` guards add conditions; `_` is the discard catch-all
  • The result type is the best common type of the arms, or target-typed (C# 9) from the context
  • Non-exhaustive switch expressions warn CS8509 and throw SwitchExpressionException at run time
  • Switching on tuples `(state, trigger) switch { ... }` gives compact state machines and decision tables
  • Arms can throw (`_ => throw new ...`) because throw is an expression
  • Use a switch statement when arms need several statements, side effects or early returns

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Statement vs expression

// 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

2. Arms, guards and discards

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
};
  • Each arm: 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.

3. Target typing of the result

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.

4. Exhaustiveness and SwitchExpressionException

  • The compiler warns (CS8509) when not every possible input is handled. For enums, you need _ even when all named values are listed, because enums can hold any integer.
  • At run time, an unmatched input throws SwitchExpressionException (with the unmatched value in UnmatchedValue).
  • Treat CS8509 as an error and add an explicit discard that throws a meaningful exception (ArgumentOutOfRangeException/UnreachableException) for impossible cases.

5. Tuple switches for state machines

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.

6. When a switch expression is the wrong tool

  • Arms need multiple statements or side effects (I/O, logging, several updates) — use a switch statement or methods.
  • The same type switch is duplicated in many places for an open hierarchy — polymorphism is better.
  • Arms have long, complex expressions — extract methods or use a lookup table.
  • The mapping is data, not logic (e.g. 200 country codes → names) — use a dictionary/FrozenDictionary or configuration.
  • You need async work in arms — you can return tasks (=> SendAsync(...)) but awaiting inside arms isn't possible; a switch statement with await reads better.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Lowering

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).

2. Jump tables and performance

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.

3. Exhaustiveness details

  • bool inputs: true and false arms suffice.
  • Nullable inputs: null must be covered (or _).
  • Tuples: the compiler reasons per element; (_, _) covers everything.
  • Relational patterns over numbers: the compiler can prove coverage (< 0, 0, > 0 is exhaustive for int).
  • when clauses: ignored for coverage.

4. Switch statement improvements

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:).

5. Data-driven alternatives

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.

6. Testing switch-based logic

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.

Try it yourself

  1. Convert a switch statement that only assigns a variable into a switch expression.
  2. Write a switch expression over an enum without _ and call it with an undefined value.
  3. Implement the order state machine as a tuple switch and write a transition matrix test.
  4. Try a switch expression with arms of different types and no target type.

💻 Code

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); }
    }
}

⚠ Common mistakes

1. No discard arm

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.

2. Side effects in arms

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.

3. Wrong arm order

What happens: general arms before specific ones. Why it is wrong: unreachable arms (errors) or wrong matches with guards. Do instead: specific to general.

4. Large data mappings in code

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.

5. Missing target type

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 = ...).

6. Complex expressions per arm

What happens: multi-line LINQ chains or nested ternaries inside arms. Why it is wrong: unreadable and hard to debug. Do instead: extract methods.

7. Duplicated type switches across the codebase

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.

8. Relying on when for core logic

What happens: most conditions in guards. Why it is wrong: loses exhaustiveness and pattern clarity. Do instead: use property/relational patterns where possible.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses relational or property patterns in arms. Why: Arms of a switch expression are patterns.
    How to answer

    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.

  2. When: Candidate compares with the switch statement. Why: Checks the classic switch statement and control-flow basics.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Invalid order transitions slipped through nested flags
Situation
A retail order service tracked status with booleans (IsPaid, IsShipped, IsCancelled) and nested ifs.
Signals
Support tickets for orders that were both cancelled and shipped; data fixes every week.
Investigation
Queried production data for impossible flag combinations and traced them to code paths that set flags independently.
Resolution
Replaced the flags with an OrderState enum and a (state, trigger) switch expression that throws on invalid transitions, plus a migration to map existing rows.
Prevention
Added an exhaustive unit test over all state and trigger pairs, and a database check constraint on valid states.

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].

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me