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.
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.
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.
| 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.
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
};
pattern [when condition] => expression; arms are tested top to bottom, first match wins.readonly local or be returned directly.SwitchExpressionException.break.Use a switch statement instead when each case performs multiple actions or side effects rather than producing a value.
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.
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.
return/continue.is + cast, null checks + property checks, or ranges.ToString.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.
The interviewer wants to see modern C# fluency and judgement about readability.
Green flags
SwitchExpressionException.Red flags
switch with case/break.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.
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).
The compiler checks whether every possible input value is matched:
bool, both true and false must be covered._ 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.null on reference types, a missing null arm produces a warning when nullable analysis says the input may be null._ 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.isinst plus a null check — x is Foo f is both safer and faster than as + null check because it is one operation.{ Customer.Address.City: "Sydney" } is shorthand for nested property patterns and implicitly null-checks each level.Deconstruct, which records generate automatically.is > 0 and < 100 or 999. Precedence is not > and > or; use parentheses.Length/Count and an indexer (arrays, List<T>, spans), and a slice pattern .. works with ranges: [var first, .., var last].== for constant tests on most types; is null is a pure reference check.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.
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.
_, observe CS8509, then call it with (MyEnum)42.int) before a specific one (int n when n < 0) and observe CS8510.if/else chain from your own code as a property-pattern switch expression and compare readability.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");
}
}
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.
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.
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.
a + b; or other no-op expression statementsWhat 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).
=> 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.
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.
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.
is followed by a castWhat 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).
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).
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.
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 |
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.
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.
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.
Last updated 30 Oct 2026.