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

📚 Interview question

What is the difference between an expression and a statement in C#? Explain switch expressions, pattern matching and expression-bodied members, and when you would use each.

Expressions versus statements in C# 14, and the expression-oriented features built on that split: switch expressions, the pattern kinds, throw expressions and expression-bodied members.

Mid-Level Theory Intermediate csharp expressions language-basics pattern-matching statements switch-expression

🎯 Key points

  • An expression evaluates to a value (or is void-typed like a method call); a statement performs an action and controls flow
  • Statements include declarations, expression statements, if, switch, loops, try, using, return and throw
  • Switch expressions (C# 8) return a value, have no fall-through, and warn (CS8509) if not exhaustive
  • A non-exhaustive switch expression throws SwitchExpressionException at run time when nothing matches
  • Pattern kinds: type, constant, relational, logical (and/or/not), property, positional, var, discard and list patterns
  • Arms are tested in order; the compiler errors on arms that can never match (subsumed)
  • Expression-bodied members (=>) suit one-expression methods, properties and constructors; throw expressions fit inside ?? and ?:
  • Use statements when there are side effects or multiple steps; use expressions to compute a value without mutation

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

An expression produces a value (and has a type); a statement performs an action and controls flow. Modern C# keeps moving logic from statements to expressions — switch expressions, patterns, expression-bodied members, throw expressions — because expressions compose, can be assigned, and let the compiler check exhaustiveness.

1. Expressions vs statements

Expression Statement
Produces a value Yes (a + b, x > 0, GetTotal(), n switch { ... }) No
Has a type Yes No
Can appear where a value is needed Yes No
Examples literals, calls, lambdas, new, ?:, ??, switch expression, throw (C# 7+) if, switch statement, loops, return, try, declarations, blocks

An expression statement is an expression used for its side effect (count++;, Save();). Only assignments, calls, increments, await and new can be used that way — a + b; is error CS0201.

2. Switch expressions (C# 8+)

decimal shipping = order switch
{
    { Total: >= 100m }                    => 0m,
    { Country: "AU", IsExpress: true }    => 15m,
    { Country: "AU" }                     => 8m,
    { Country: "NZ" }                     => 20m,
    null                                  => throw new ArgumentNullException(nameof(order)),
    _                                     => 35m
};
  • Each arm is pattern [when condition] => expression; arms are tested top to bottom, first match wins.
  • It is an expression, so it returns a value and can initialise a readonly local or be returned directly.
  • The compiler checks exhaustiveness (warning CS8509) and unreachable arms (error CS8510). If no arm matches at run time, it throws SwitchExpressionException.
  • No fall-through and no break.

Use a switch statement instead when each case performs multiple actions or side effects rather than producing a value.

3. Pattern matching

Patterns test a value's shape and optionally extract parts of it:

Pattern Example
Type / declaration if (shape is Circle c)
Constant status is OrderStatus.Paid, x is null
Relational (C# 9) age is >= 18 and < 65
Logical (C# 9) not, and, or
Property order is { Status: Paid, Customer.Country: "AU" } (extended property patterns C# 10)
Positional point is (0, 0) using Deconstruct
var is var temp captures without testing
Discard _ matches anything
List (C# 11) args is ["run", var file, ..]

Patterns replace chains of is/cast/null-check code, and the compiler guarantees the extracted variables are definitely assigned and non-null where it can prove it.

4. Expression-bodied members

public decimal Total => Lines.Sum(l => l.Amount);           // read-only property
public override string ToString() => $"{Id}: {Total:C}";    // method
public Order(Guid id) => Id = id;                          // constructor
public string Name { get => _name; set => _name = value.Trim(); }

They are pure syntax — the IL is identical to a block body. Use them when the member is a single, obvious expression; switch to a block when you need multiple statements, logging, guards or debugging breakpoints.

5. When I use each

  • Switch expression: mapping a value to a value — status to HTTP code, enum to display text, domain type to DTO, discount by tier.
  • Switch statement / if-else: branches with side effects, multiple statements, or early return/continue.
  • Pattern matching: anywhere you would write is + cast, null checks + property checks, or ranges.
  • Expression-bodied members: computed properties, trivial forwarding methods, ToString.

6. A production example

In an API that mapped domain results to HTTP responses, a 60-line if/else chain with casts (if (result is NotFoundError) { var e = (NotFoundError)result; ... }) kept missing new error types — someone added ConflictError and it fell through to a 500. Rewriting it as a switch expression over a closed hierarchy of result types made the mapping a single table-like block, and after we turned CS8509 into an error (<WarningsAsErrors>CS8509</WarningsAsErrors>) a new error type that was not mapped broke the build instead of returning a 500 in production.

🔍 What the interviewer is checking

The interviewer wants to see modern C# fluency and judgement about readability.

Green flags

  • Clear definition: expressions produce values, statements perform actions.
  • Writes a switch expression with relational or property patterns without hesitation.
  • Knows about exhaustiveness warnings and SwitchExpressionException.
  • Explains when a statement is still the better choice.

Red flags

  • Only knows the classic switch with case/break.
  • Thinks a switch expression can fall through.
  • Uses nested ternaries five levels deep and calls it clean.

📚 Learn in depth

1. The grammar distinction

The C# grammar has two separate categories: expression and statement. A method body is a list of statements; expressions only appear inside statements (as conditions, initialisers, arguments, or as an expression statement). Since C# 7 the language has been adding expression forms of statement-like constructs:

Statement form Expression form Version
throw statement throw expression (x ?? throw ...) C# 7.0
switch statement switch expression C# 8.0
if/else ?: conditional (target-typed since C# 9) 1.0 / 9.0
block-bodied member expression-bodied member C# 6 / 7
out variable declared before call out var declaration expression C# 7.0

Why this trend? Expressions are composable (you can nest them), make immutability easy (var x = ... assigned once), and let the compiler reason about completeness.

2. How the compiler lowers a switch expression

The compiler builds a decision DAG (directed acyclic graph) from all the patterns, sharing tests between arms so each input is examined as few times as possible. For example, type tests are done once, property values read once, and constant comparisons may become a jump table (switch IL opcode) or a binary search.

string Describe(object o) => o switch
{
    int n when n < 0 => "negative int",
    int              => "int",
    string { Length: 0 } => "empty string",
    string s         => $"string of {s.Length}",
    null             => "null",
    _                => o.GetType().Name
};

Lowered, this is roughly: one isinst Int32 check, one isinst String check, a single Length read, and branches. Because the DAG is built from all arms, the compiler can also prove subsumption — an arm that can never be reached because an earlier arm covers it is an error (CS8510).

3. Exhaustiveness

The compiler checks whether every possible input value is matched:

  • For bool, both true and false must be covered.
  • For enums, the compiler requires a discard _ even if you list every named member, because an enum variable can hold any underlying integer ((OrderStatus)99). That is exactly the case SwitchExpressionException protects against.
  • For null on reference types, a missing null arm produces a warning when nullable analysis says the input may be null.
  • For class hierarchies, C# does not (yet) know a hierarchy is closed, so a _ arm is needed. Discriminated unions are on the language roadmap; until then a common trick is an abstract base with a private constructor and nested sealed subclasses.

4. Patterns in depth

  • Type patterns compile to isinst plus a null check — x is Foo f is both safer and faster than as + null check because it is one operation.
  • Property patterns call property getters; they are not free if the getter does work. Since C# 10, { Customer.Address.City: "Sydney" } is shorthand for nested property patterns and implicitly null-checks each level.
  • Positional patterns call Deconstruct, which records generate automatically.
  • Relational and logical patterns combine: is > 0 and < 100 or 999. Precedence is not > and > or; use parentheses.
  • List patterns (C# 11) work on any type with Length/Count and an indexer (arrays, List<T>, spans), and a slice pattern .. works with ranges: [var first, .., var last].
  • Patterns do not call user-defined == for constant tests on most types; is null is a pure reference check.

5. Expression-bodied members under the hood

public int Count => _items.Count; produces exactly the same IL as public int Count { get { return _items.Count; } }. There is no runtime difference. A subtle trap is confusing

public List<Item> Items => new();      // NEW list on every access!
public List<Item> Items { get; } = new(); // one list created at construction

The first is a computed property that allocates each time and loses any items added; the second is an auto-property with an initialiser.

6. Statements still matter

Some things are only statements: loops, try/catch/finally, using blocks, lock, return, yield. Trying to force everything into expressions (deeply nested ternaries, side effects inside switch-expression arms via helper calls) hurts readability and debugging — you cannot easily set a breakpoint on one arm of a big expression in some IDEs, and stack traces point at a single line.

Try it yourself

  1. Write a switch expression over an enum without _, observe CS8509, then call it with (MyEnum)42.
  2. Put a broad arm (int) before a specific one (int n when n < 0) and observe CS8510.
  3. Paste a switch expression into SharpLab and read the lowered C# to see the decision tree.
  4. Rewrite an if/else chain from your own code as a property-pattern switch expression and compare readability.

💻 Code

namespace Basics;

public enum Zone { Metro, Regional, Remote }
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Customer(string Country, int Age, bool IsMember);

public static class Expressions
{
    // Tuple pattern + relational patterns
    public static decimal ShippingRate(Zone zone, decimal kg) => (zone, kg) switch
    {
        (_, <= 0m)                 => throw new ArgumentOutOfRangeException(nameof(kg)),
        (Zone.Metro, <= 5m)        => 9.95m,
        (Zone.Metro, _)            => 14.95m,
        (Zone.Regional, <= 5m)     => 14.95m,
        (Zone.Regional, _)         => 24.95m,
        (Zone.Remote, _)           => 39.95m,
        _                          => throw new ArgumentOutOfRangeException(nameof(zone))
    };

    // Type patterns with property patterns
    public static double Area(Shape shape) => shape switch
    {
        Circle { Radius: var r }          => Math.PI * r * r,
        Rectangle { Width: var w, Height: var h } => w * h,
        _ => throw new NotSupportedException(shape.GetType().Name)
    };

    // Property + logical patterns
    public static bool CanBuyAlcohol(Customer c) => c is { Country: "AU", Age: >= 18 };

    // List patterns (C# 11)
    public static string Describe(int[] values) => values switch
    {
        []             => "empty",
        [var only]     => $"one item: {only}",
        [var first, .., var last] => $"{first}..{last}"
    };

    // Statement form where there are side effects
    public static void Process(Customer c)
    {
        if (c is { IsMember: true })
        {
            Console.WriteLine("Apply member discount");
            return;
        }
        Console.WriteLine("Standard pricing");
    }
}

⚠ Common mistakes

1. Forgetting the default arm on enum switches

What happens: a switch expression lists every enum member and has no _ arm. Why it is wrong: enums can hold any integer value (from casts, deserialisation or the database), so an unmatched value throws SwitchExpressionException at run time. Do instead: add _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) so the failure is explicit and descriptive, and treat CS8509 as an error.

2. Ordering arms from general to specific

What happens: object => ... or int => ... placed before more specific arms. Why it is wrong: arms are evaluated top to bottom and the first match wins; later arms become unreachable (CS8510) or, with when clauses, silently never match. Do instead: order from most specific to most general and keep the discard last.

3. Using a switch expression for side effects

What happens: each arm calls a method that sends an email or writes to the database, and the result is assigned to a dummy variable. Why it is wrong: switch expressions are for producing values; side effects hidden in arms are surprising and hard to debug. Do instead: use a switch statement, or have the expression return the action (a delegate or command object) and execute it separately.

4. Writing a + b; or other no-op expression statements

What happens: name.Trim(); or date.AddDays(1); on their own lines. Why it is wrong: these are calls whose result is discarded — strings and DateTime are immutable, so nothing changes. The compiler only rejects pure expressions like a + b;, not ignored return values. Do instead: assign the result (name = name.Trim();) and enable analysers such as CA1806 (do not ignore method results).

5. Confusing => new() with { get; } = new()

What happens: public List<Item> Items => new(); Why it is wrong: an expression-bodied property re-evaluates on every access, so every read returns a fresh empty list and additions are lost. Do instead: use an auto-property with an initialiser for state; use => only for genuinely computed values.

6. Expression-bodied members that hide complexity

What happens: a single => line chains several LINQ calls, database access or a nested ternary. Why it is wrong: properties are expected to be cheap; heavy work in an expression-bodied property is called repeatedly (including by the debugger and serialisers). Do instead: keep => for trivial logic; turn expensive work into a method with a descriptive name.

7. Over-nesting ternaries instead of a switch expression

What happens: a ? x : b ? y : c ? z : w. Why it is wrong: hard to read, easy to misplace a branch, and no exhaustiveness check. Do instead: use a switch expression with patterns, which reads like a table.

8. Using is followed by a cast

What happens: if (shape is Circle) { var c = (Circle)shape; ... } Why it is wrong: two type checks instead of one, and the cast can drift from the test during refactoring. Do instead: use a declaration pattern: if (shape is Circle c).

9. Misreading pattern-combinator precedence

What happens: x is not null or "" expected to mean "not null and not empty". Why it is wrong: not binds tighter than or, so it means (not null) or "", which is true for almost everything. Do instead: write x is not (null or ""), or better, !string.IsNullOrEmpty(x).

10. Assuming property patterns are free

What happens: property patterns over properties that run queries or lazy loading (e.g. EF Core navigation properties with lazy loading on). Why it is wrong: each tested property may trigger work or database calls. Do instead: pattern-match over plain data (DTOs, records) or load what you need first.

🏛 Senior insight: trade-offs

Expression-oriented C# reduces mutable state, and exhaustiveness checking turns missing business rules into compile-time warnings.

Use Prefer
Mapping input to a value (pricing, status to HTTP code) switch expression
Multi-step logic, I/O, early exit, logging statements
Validating object shape property patterns (is { ... })
Polymorphic behaviour that changes often virtual methods or strategy objects
Closed set of cases (discriminated-union style records) switch expression over a sealed hierarchy
  • Switch expressions over types are the C# answer to "closed" hierarchies; when new subtypes are added often by other teams, polymorphism scales better (open/closed principle).
  • Make CS8509 an error in domain projects so a new enum member forces every mapping to be updated.
  • Patterns compile to efficient decision trees, so readability rarely costs performance.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate says statements are for control flow and side effects. Why: Checks the statement side: loops, jumps and the bugs they cause.
    How to answer

    C# has selection statements (if, switch), iteration (for, foreach, while, do) and jumps (break, continue, return, goto, throw, yield). foreach over an array compiles to an index loop; over anything else it calls GetEnumerator, loops on MoveNext/Current, and disposes the enumerator in a finally. The bugs I watch for are modifying a list while iterating (it throws), closures capturing a for loop variable (they all see the last value), off-by-one bounds, and enumerating a lazy LINQ query twice.

  2. When: The candidate uses a throwing discard arm for unexpected input. Why: Switch expressions often map results to values; this tests return-type design for absence and failure.
    How to answer

    The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.

🛠 Real-world problems: how you handle them

Missing enum case in a pricing switch charged remote customers nothing
Situation
An e-commerce checkout service computed freight with a switch statement that set a local variable; a new Zone.Remote enum member was added for WA and NT deliveries.
Signals
Orders to remote postcodes showed $0.00 freight; margin report flagged a loss on freight for a week.
Investigation
Traced an order through logs, reproduced with a unit test for Zone.Remote, and found the switch statement had no case and the variable kept its default of 0.
Resolution
Rewrote the calculation as a switch expression with a discard arm that throws, added the Remote rate, and back-billed affected B2B orders where contracts allowed.
Prevention
Made CS8509 an error in the Pricing project, added a test that iterates Enum.GetValues<Zone>() and asserts every zone has a rate, and added a freight = 0 alert.

How to tell it (STAR)

**S:** At [company], remote-area orders started shipping with zero freight after we added a new delivery zone. **T:** I needed to find out why and make sure a new zone could never be missed again. **A:** I reproduced it, found the switch statement silently left the default value, rewrote it as an exhaustive switch expression, and turned the exhaustiveness warning into a build error. **R:** Freight was correct again the same day, we recovered [amount], and the next zone change failed the build until the rate was added.

↩ Usually asked after

More in Language Basics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me